Files
ulthon_admin/.agents/skills/ulthon-testing/SKILL.md
augushong e392db007a docs(agents): 落实按主题单一文档原则,合并规则到对应技能
- AGENTS.md 代码分层铁律精简为入口摘要,链接指向技能详情
- 合并 ulthon-timer-multi-node 规则到 ulthon-timer 技能(多节点协调章节)
- 合并 ulthon-database-design 规则到 ulthon-scheme-definition 技能(含字段约定、组件类型)
- 合并 ulthon-testing 规则到 ulthon-testing 技能(含设计哲学、决策树、测试约束)
- rules-manager 边界原则从规则/技能二分改为按主题单一文档
- AGENTS.md 通用基础规范中表结构规范链接改向技能
- 工作流索引中三个技能描述扩充(明确承载原规则内容)
2026-07-19 09:07:42 +08:00

409 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
name: "ulthon-testing"
description: "框架测试完整指南:设计哲学(控制器中心主义、按需 service、决策依据4 层模型、该不该抽 service、测试约束专用测试库、事务回滚、如何运行与编写测试、环境设置、回归保护验证。"
---
# 测试工作流(设计 + 操作)
本技能是框架级(`ulthon-` 前缀)的测试完整指南。涵盖:为什么这样设计、什么时候该写测试、必须遵守的约束、具体怎么运行和编写测试。
测试栈基线PHPUnit 10.5.x`composer.json``phpunit/phpunit: ^10`ThinkPHP 8 容器,参数化测试库(复用 .env 连接database = env+'_test'),事务回滚做隔离。
## 何时调用
- 第一次在这个项目跑测试,不知道怎么准备环境
- 要给一段状态流转、致命约束、软删除相关的 service 逻辑加回归测试
- 拿到一个 bug想先用测试锁住复现路径再修
- 不确定该写 phpunit 还是直接用 `tools:http:call`
- 判断"该不该抽 service"、"这段逻辑要不要写测试"
## 设计哲学
防止后人或 AI 把框架的刻意设计误判为缺陷,这里把四条取向讲透。回答两个问题:(1) 框架里那么多业务逻辑没写单元测试,是不是不规范?(2) 哪些逻辑必须抽 service 并锁测试?回答之前先理解框架的设计取向,否则会把"设计如此"误判成"技术债"去重构,反而制造破坏。
### 控制器中心主义
业务逻辑写在控制器里,是设计如此,不是不规范。
框架侧重控制器。控制器等于接口等于页面,业务入口、参数校验、结果反馈都在这里完成。一个控制器方法同时承担"渲染页面"和"返回 JSON"两种职责(见下文"页面接口同体"),所以把业务流程写在控制器里,流程跟入口是同一段代码,改起来最直接,调试链路最短。
不要因为"控制器太胖"就机械地拆 service。控制器胖是因为业务就发生在这里把业务搬到 service 只是把同样的代码换个文件,还多了一层跳转成本。
### 页面接口同体
一套控制器代码同时服务三端:后台管理页、用户端接口、小程序接口。消除"接口分叉"是这套机制的核心价值。
严禁拆成 `ApiController` + `PageController` 两套。一旦拆开,三端逻辑就会各自漂移,今天改了页面端忘了同步接口端,明天接口端加了字段页面端没跟上,最终三端行为不一致。同体机制的价值有五条:
1. **一套逻辑不会分叉**:同一方法同一代码路径,三端拿到的结果数学上完全一致
2. **接口测试 ROI 放大**:测一条路径等于同时覆盖页面和接口,不用维护两套测试
3. **改一处生效三端**:业务变更只改一个方法,三端同步生效,没有"忘了同步"的窗口
4. **框架机制保证三模式自动切换**:同体不是手写 if-else 判断请求类型,框架按请求特征自动选择渲染或 JSON 响应,开发者无感
5. **这不是图省事,是消除分叉风险**:同体是架构决策,不是偷懒。拆开的代价(三端不一致的线上事故)远大于合并的代价(一个方法稍长)
详细机制见技能 [ulthon-page-api-dual-mode](./ulthon-page-api-dual-mode/SKILL.md)。
### 按需 service
框架对 service 的规范是:**多应用复用才放 `app/common/service/`**。
大多数应用业务不需要封装 service。把"只用一次的业务流程"硬抽成 service只是把代码搬离控制器没有复用收益反而增加了跳转和传参成本。
只有两种情况才值得抽 service
- **(a) 产品工具型逻辑**:算法、编号解析、格式转换这类"输入输出确定、不变、多处用"的逻辑。例如编号生成器、二维码解析器。它们是工具,不是业务流程
- **(b) 不可逆且致命的约束**:错了会出真实事故的校验逻辑。例如余额扣减的"不能扣成负数"、绑定约束的"不能重复绑定"、状态流转的"不可逆转换"。这种逻辑必须从控制器流程里独立出来,单独测、单独审、单独防回归
### 应用业务 vs 产品工具
这两类代码的价值取向完全相反,不能用同一套规范套。
- **应用业务**追求快速响应变化。业务规则经常变(今天满减门槛 100 元,明天改 80 元),逻辑写死在控制器里反而最好改。过度封装 service 和写单元测试,会让"改一个数字"变成"改 service + 改测试 + 改 mock 数据"三件事,成为响应变化的障碍
- **产品工具**追求绝对正确性。sqlite 管理、网盘协议、解析器这类工具,输入输出契约一旦确定就不该变,错了就是工具本身坏了。这里 service 和单元测试很有价值,因为不变量是死的,测试能长期守护
判断一段逻辑属于哪类:问"它会变吗,还是它该永远对?"。会变的是应用业务,该永远对的是产品工具。
## 该不该抽 service / 该不该写测试(决策树)
判断标准有且只有两个维度:**错了的后果** + **是否多处复用**。注意,判断标准不是"会不会变"。业务会变不代表不该封装,余额规则再怎么变,"不能扣成负数""不能重复扣"这些不变量是死的,值得锁住。
```
这段逻辑错了会怎样?
├─ 后果不可承受(钱/医疗/不可逆/法律)→ 必须抽 service + 写测试
│ 余额扣减、状态流转、绑定约束、支付回调、库存扣减...
├─ 后果可承受,但逻辑被多处复用 → 抽 serviceDRY测试看情况
│ 消息发送、文件上传、数据导出、编号生成...
└─ 后果可承受,且只用一次 → 写控制器里http:call 验证就够
字段名、展示顺序、列表筛选、表单校验、页面跳转...
```
三条分支的落地:
- **后果不可承受**:抽到 `app/common/service/`,配 PHPUnit 集成测试。测试要锁死不变量,不只是覆盖当前行为
- **后果可承受 + 多处复用**:抽 service 是为了 DRY不是为了测试。测试看 ROI纯函数值得测带状态的可放可不放
- **只用一次**:留在控制器里。用 `php think tools:http:call`(见技能 ulthon-tools-http-call模拟请求跑通增删改查即可这是应用业务的主力验证方式不需要 PHPUnit
## 4 层测试模型
大部分应用业务逻辑【不测】。只在下表对应层介入。
| 层 | 测什么 | 工具 | 适用场景 |
|------|--------|------|----------|
| 纯函数 | 编号解析、算法、格式转换 | 裸 `assert` / 极简 PHPUnit | 产品工具型逻辑 |
| 控制器 | 列表、表单、筛选、跳转、增删改查 | `php think tools:http:call` | 应用业务主力 |
| 核心 service | 致命约束、状态流转、不可逆操作 | PHPUnit 集成测试TestCaseBase | 不可逆且致命 |
| 并发专项 | 抢购、库存扣减、临界区 | 独立并发脚本 | 高并发临界区 |
说明:
- **控制器层**用 `tools:http:call` 而非 PHPUnit因为它能跑通"鉴权 + 路由 + 中间件 + 控制器 + 模型 + 视图"整条链PHPUnit 单测控制器往往需要 mock 一堆依赖ROI 很低
- **核心 service 层**才用 PHPUnit 集成测试,且必须继承 `app\common\test\TestCase`(见"测试约束"),跑在真实测试库 + 事务回滚隔离里
- **纯函数层**可以用裸 `assert`(一个 `php` 脚本跑完),不一定要进 PHPUnit 套件,除非逻辑足够复杂值得长期守护
## service 层测试边界
"多应用复用才放 `app/common/service/`"和"可测性需求"之间有张力:有些逻辑只用一次,但错了后果不可承受。
何时破例:**致命约束型 service 值得破例抽出来测**。即使一段逻辑只在当前应用的某个流程里用一次,只要它错了是资金事故/法律事故/安全事故(决策树第一分支),就值得抽成 service 配测试。理由是测试需要稳定的入口和可复现的 fixture控制器方法很难提供这两个条件请求上下文、事务、鉴权状态都会干扰service 的纯函数式签名天生适合测试。
反过来说,后果可承受的逻辑(决策树第二、三分支)不要为了"可测"硬抽 service。留在控制器里用 `tools:http:call` 验证,更符合应用业务的响应变化需求。
典型破例场景:余额扣减的"不能扣成负数"、唯一性绑定约束、不可逆状态流转、支付回调验签、库存扣减的"不能超卖"。
## 测试约束(铁律)
以下约束不可协商。
### 强制使用专用测试库
- 所有 PHPUnit 测试**必须**连专用测试库(库名 = `.env``DATABASE` 值 + `_test` 后缀,如 `ulthon_admin``ulthon_admin_test`**禁止**连开发/生产库
- 连接参数host/port/user/pass/charset/prefix`.env` 读取,复用开发库同实例,单机 MySQL 只需建一个 `_test`
- 覆盖实现位于 `tests/bootstrap.php`:在 `App::initialize()` 之后通过 config 级覆盖 `connections.main`**不能用** `putenv` / `$env:VAR` / bash 前缀变量,因为 ThinkPHP 的 Env 加载 `.env``.env` 值优先于 OS 环境变量)
### 第一道安全闸门
`base\common\test\TestCaseBase::setUp()` 的第一步是 `assertTestDatabase()`:读当前连接的库名,若不含子串 `test`(大小写不敏感),立即 `fail()` 中止测试。这保证即使 `tests/bootstrap.php` 的 config 覆盖写错、或 `.env` 被误改指向生产库,测试也绝不会对生产库写入任何数据。
### 业务测试必须继承 app 层入口
业务测试用例**必须**继承 `app\common\test\TestCase`(继承自内核 `base\common\test\TestCaseBase`)。**不要**直接继承 `TestCaseBase`(那是内核层),也不要直接继承 `PHPUnit\Framework\TestCase`(拿不到 fixture 工厂和事务隔离)。
### 隔离手段 = 事务回滚(禁止手动清表)
- `TestCaseBase::setUp()` 开启事务,`tearDown()` 自动 `Db::rollback()` 撤销本测试的一切 DB 写入
- **禁止**在测试里手动 `DELETE` / `TRUNCATE` 清数据
- 怀疑隔离失效时可调基类的 `assertIsolationWorks()` 自验证;极端降级方案 `truncateTables(array $tables)` 正常情况下用不到
### 业务 fixture 工厂由项目自实现
- 框架层(`TestCaseBase`只提供通用基建事务回滚隔离、DB 断言(`assertDatabaseHas` / `assertDatabaseMissing`、隔离探针、Plan B`truncateTables`
- **不提供**业务 fixture 工厂(如 `createUser` / `createOrder` 等)
- 各衍生项目在自己的 `app/common/test/TestCase` 中实现业务工厂方法,工厂方法运行在 `setUp` 事务内,`tearDown` 自动回滚
## 如何运行测试
### 首次准备(建测试库 + 建表,只做一次)
测试库复用开发库同实例的连接参数host/port/user/pass 从 .env 读),仅库名不同(= .env 的 `DATABASE` + `_test`)。单机 MySQL 建一个 `_test` 库即可,无需专用 Docker 容器。
第一步,建测试库(一次性,用 root 连接):
```sql
CREATE DATABASE IF NOT EXISTS ulthon_admin_test
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
第二步,建表 + 种子(测试库已建后执行):
```bash
php tests/setup_test_db.php
```
这个脚本会在同进程内依次跑 `migrate:run`(系统表)、`scheme:sync`(业务表)、`seed:run`(参考数据),并自查 `information_schema`。成功退出码 0。脚本幂等可安全重跑。
### 日常运行
全量跑(所有测试套件):
```bash
# Linux / macOS / Git Bash
vendor/bin/phpunit
# Windows PowerShell / cmd
vendor\bin\phpunit
```
跑单个测试文件:
```bash
vendor/bin/phpunit tests/Feature/IsolationSmokeTest.php
```
只跑某个测试方法(`--filter` 匹配方法名片段):
```bash
vendor/bin/phpunit --filter test_isolation
```
按套件跑(`phpunit.xml.dist` 定义了 testsuite具体名称以该文件为准
```bash
vendor/bin/phpunit --testsuite feature
```
注意Windows 下路径用反斜杠 `vendor\bin\phpunit`Git Bash / WSL 下用正斜杠。
## 如何编写新测试
### 继承应用层入口
集成测试一律继承 `app\common\test\TestCase`
```php
namespace tests\Integration;
use app\common\test\TestCase;
class XxxTest extends TestCase
{
// ...
}
```
继承之后,基类自动帮你做两件事,你完全不用操心:
- `setUp`:先校验当前连的是测试库(库名含 `test` 才放行,拒绝连生产库),再 `Db::startTrans()` 开事务
- `tearDown``Db::rollback()` 回滚事务,撤销本测试的一切 DB 写入
事务 API 是 `Db::startTrans()` 开启、`Db::rollback()` 回滚think-orm 3.0 没有 `rollbackTrans` 这个方法)。这两个调用都在基类里,测试作者不要自己调。
### 造数据fixture
各衍生项目在自己的 `app\common\test\TestCase` 里实现业务工厂方法。工厂方法应:
- 运行在 `setUp` 事务内,`tearDown` 自动回滚,无需手动清理
- 默认用 `Model::create()`;遇到表无 `update_time` 列但模型继承 `TimeModel` 时,回退 `Db::name()->insertGetId()`(绕开 ORM 与表结构不匹配)
- 造软删数据用 `Db::name($table)->where('id', $id)->update(['delete_time' => time()])`,绕开 `SoftDelete` trait 的拦截复杂度
### 断言
PHPUnit 原生断言照常用(`assertTrue` / `assertFalse` / `assertEquals` / `assertStringContainsString` 等)。基类额外提供两个 DB 断言助手,参数都是逻辑表名(不含前缀,和 `Db::name()` 一致):
```php
$this->assertDatabaseHas('system_admin', ['username' => 'admin']);
$this->assertDatabaseMissing('system_admin', ['username' => 'deleted_user']);
```
断言失败时,建议在 message 里带上被测方法的诊断信号。例如 service 返回的 `reason` 文案是它唯一的诊断出口,拼进 message 能立刻看出是哪道校验拒绝了:
```php
$this->assertFalse($result['ok'], 'reason: ' . ($result['reason'] ?? ''));
```
### 隔离(无需手动清理)
事务回滚是 PRIMARY 隔离手段。每个测试开事务、结束时回滚,数据不跨测试残留。
如果怀疑隔离失效(比如某个测试改了框架级单例、或跑了自动提交的 DDL可以调用基类的 `assertIsolationWorks()` 做自验证(三步探针:先断言探针行不存在,插入,再断言可见;下一个测试的第一步如果失败就说明上一个测试没回滚干净)。
## 测试模板
### 单元测试模板(纯函数 / 算法,无需 DB
纯函数测试**不要**继承 `app\common\test\TestCase`(那会触发 setUp 连测试库、开事务,拖慢且强依赖容器)。直接继承 `PHPUnit\Framework\TestCase`
```php
<?php
declare(strict_types=1);
namespace tests\Unit;
use PHPUnit\Framework\TestCase;
use app\common\service\SomePureService;
/**
* 纯函数 / 算法测试.
*
* 不连数据库,不需要测试库,跑得最快。
*/
class SomePureServiceTest extends TestCase
{
public function test_something_returns_expected(): void
{
$result = SomePureService::compute('input');
$this->assertSame('expected', $result);
}
}
```
放进 `tests/Unit/`namespace 用 `tests\Unit`
### 集成测试模板service / DB 写入 / 状态流转)
集成测试继承 `app\common\test\TestCase`,用 fixture 造数据、调 service、断言
```php
<?php
declare(strict_types=1);
namespace tests\Integration;
use app\common\service\XxxService;
use app\common\test\TestCase;
/**
* 某 service 的集成测试.
*
* 继承 TestCase 自动获得测试库连接、事务回滚隔离、DB 断言。
*/
class XxxServiceTest extends TestCase
{
public function test_happy_path(): void
{
// 1. 造前置数据fixture事务内写入自动回滚
// 用本项目 app\common\test\TestCase 里实现的工厂方法
$user = $this->createUser(['balance' => 100]);
// 2. 调被测逻辑
$result = XxxService::deduct($user, 30);
// 3. 断言(原生 + DB 断言)
$this->assertTrue($result['ok']);
$this->assertDatabaseHas('user', ['id' => $user->id, 'balance' => 70]);
}
public function test_invariant_locked(): void
{
// 锁死不变量:余额不能扣成负数
$user = $this->createUser(['balance' => 10]);
$result = XxxService::deduct($user, 100);
$this->assertFalse($result['ok']);
$this->assertDatabaseHas('user', ['id' => $user->id, 'balance' => 10]); // 余额未变
}
}
```
放进 `tests/Integration/`namespace 用 `tests\Integration`
## 测试类型速查
被测对象 → 工具/路径 的快速映射。选对工具后跳到对应小节查看详细操作。
| 被测对象 | 用什么 | 放哪 |
|----------|--------|------|
| 纯函数 / 算法 / 格式化 | phpunit 单元测试 | `tests/Unit/` |
| 致命约束 / 状态流转 service余额扣减、状态机、软删历史、绑定约束 | phpunit 集成测试 | `tests/Integration/` |
| 控制器列表 / 表单 / 筛选 / 增删改查链路 | `tools:http:call` | 不写 phpunit |
| 并发临界区 / 竞态(抢购抢锁等) | 独立并发脚本 | `runtime/agents/` 或临时脚本 |
## 如何设置测试环境
完整步骤(首次或重装时执行):
1. **装依赖**`composer install``phpunit/phpunit: ^10` 已在 require-dev
2. **建测试库**:用 root 连接执行 `CREATE DATABASE {env.database}_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`(库名 = .env 的 `DATABASE` + `_test`
3. **建表 + 种子**`php tests/setup_test_db.php`
为什么建表必须跑两套机制(这是框架最重要的特点之一,缺一不可):
| 机制 | 命令 | 来源目录 | 建什么 |
|------|------|----------|--------|
| Phinx 迁移 | `migrate:run` | `database/migrations/` | 系统表system_admin / system_menu / system_auth_node / system_config / system_host / system_timer_* 等) |
| Scheme 同步 | `scheme:sync` | `app/admin/scheme/` | 业务表(各项目自己用 PHP 8 Attribute 声明的表) |
只跑 migrate 拿不到业务表,只跑 scheme 拿不到系统表(框架无法登录鉴权)。`setup_test_db.php` 在同进程内按顺序调这三条命令migrate:run / scheme:sync / seed:run并对每条带 `--force-force` 跳过交互确认(非 TTY 下 `scheme:sync` 会因 confirm 抛 Aborted
为什么用 config 级覆盖而不是环境变量ThinkPHP 的 Env 加载 `.env`,且 `.env` 值优先于 OS 环境变量。`putenv('DATABASE=...')``$env:DATABASE=...`、bash 前缀变量都无法覆盖 `.env` 里已有的连接键。唯一可靠覆盖点是 config 级,在 `App::initialize()` 之后改写 `config/database.php` 解析出的连接数组。`tests/bootstrap.php``tests/setup_test_db.php` 都用这个模式。
注意一个 think 8 的坑:`Config::set(array, $name)` 的第二参数只支持单段一级名,不像 `Config::get` 那样解析点号路径。正确写法是把整段 `database` 配置 pull 出来,原地改 `connections.main`,再 `Config::set($dbConfig, 'database')` 写回。
彻底重置测试库:
```bash
mysql -uroot -proot -e "DROP DATABASE IF EXISTS ulthon_admin_test; \
CREATE DATABASE ulthon_admin_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
php tests/setup_test_db.php
```
## 如何验证回归保护(破坏 + 恢复法)
写完一个不变量级测试后,要确认它真的能抓住回归。光跑绿不够,因为一个永远绿的测试可能是空壳。用"破坏 + 恢复法"五步验证:
1. **基线绿**:先跑测试,确认全绿。这是起点
2. **临时破坏**:把生产代码里被锁的逻辑临时改错。例如把"余额不能扣成负数"的校验临时注释掉,或把"拒绝重复绑定"改成"允许"
3. **跑测试变红**:再跑测试,相关用例应该失败。如果这步还是绿,说明测试没锁住这个约束,白写了,回去补用例
4. **回退代码**`git checkout -- app/common/service/XxxService.php`(或对应文件),恢复生产代码原状
5. **恢复绿**:再跑测试,确认全绿。证明破坏是测试抓到的,不是环境问题
这五步走完这个测试的回归保护才算真的成立。之后任何人改这段代码CI 会替你拦住。
## 自查清单
写测试或动 service 前,对照自查:
- [ ] 这段逻辑错了后果可承受吗?("设计哲学" + "决策树")后果可承受且只用一次,留在控制器 + `tools:http:call`,不要硬抽 service
- [ ] 如果错了不可承受,抽成 service 了吗?配 PHPUnit 集成测试了吗4 层模型"核心 service"层)
- [ ] 测试继承 `app\common\test\TestCase` 了吗?连的是 `_test` 库吗?(测试约束)
- [ ] fixture 用工厂方法了吗?没有手动 truncate 吧?(如何编写新测试)
- [ ] 致命约束的不变量用测试锁死了吗?(不只是覆盖当前行为,要锁死"不能怎样"的约束)
## 相关文件
| 文件 | 作用 |
|------|------|
| `tests/bootstrap.php` | PHPUnit 引导autoload + 容器初始化 + config 级覆盖到测试库 |
| `tests/setup_test_db.php` | 测试库建表脚本migrate:run + scheme:sync + seed:run + 自验证 |
| `tests/setup_test_db.ps1` / `.sh` | Windows / Linux 包装器 |
| `tests/README.md` | 测试库建库与建表的完整说明 |
| `phpunit.xml.dist` | PHPUnit 配置testsuite 定义bootstrap 指向 tests/bootstrap.php |
| `extend/base/common/test/TestCaseBase.php` | 内核层测试基类安全校验、事务隔离、DB 断言、探针 |
| `app/common/test/TestCase.php` | 应用层入口:继承 TestCaseBase各项目在此实现业务 fixture 工厂 |
| `tests/Feature/IsolationSmokeTest.php` | 隔离机制冒烟测试(探针回滚 + 拒绝非测试库) |
交叉参考:控制器联调验证见 [ulthon-tools-http-call](./ulthon-tools-http-call/SKILL.md)Base/App 架构分层见 [ulthon-base-app-architecture](./ulthon-base-app-architecture/SKILL.md)。