--- name: "ulthon-testing" description: "框架测试工作流:如何运行测试、编写新测试(继承/fixture/事务回滚)、选择测试类型、设置测试环境、验证回归保护。需要跑 phpunit 或给致命约束/状态流转逻辑加测试时调用。" --- # 测试工作流(运行 + 编写 + 决策) 本技能是框架级(`ulthon-` 前缀)的测试入门指南。读完这一篇,新开发者或 AI 就能:把测试跑起来、照模板写一个新测试、选对测试工具、设置测试环境。 测试栈基线:PHPUnit 10.5.x(`composer.json` 里 `phpunit/phpunit: ^10`),ThinkPHP 8 容器,参数化测试库(复用 .env 连接,database = env+'_test'),事务回滚做隔离。 ## 何时调用 - 第一次在这个项目跑测试,不知道怎么准备环境。 - 要给一段状态流转、致命约束、软删除相关的 service 逻辑加回归测试。 - 拿到一个 bug,想先用测试锁住复现路径再修。 - 不确定该写 phpunit 还是直接用 `tools:http:call`。 ## 1. 如何运行测试 ### 1.1 首次准备(建测试库 + 建表,只做一次) 测试库复用开发库同实例的连接参数(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; ``` (库名 = .env 的 `DATABASE` 值 + `_test`,如 `ulthon_admin` → `ulthon_admin_test`) 第二步,建表 + 种子(测试库已建后执行): ```bash php tests/setup_test_db.php ``` 这个脚本会在同进程内依次跑 `migrate:run`(系统表)、`scheme:sync`(业务表)、`seed:run`(参考数据),并自查 `information_schema`。成功退出码 0。脚本幂等,可安全重跑。 ### 1.2 日常运行 全量跑(所有测试套件): ```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 下用正斜杠。 ## 2. 如何编写新测试 ### 2.1 继承应用层入口 集成测试一律继承 `app\common\test\TestCase`。不要直接继承 `base\common\test\TestCaseBase`(那是内核层),也不要直接继承 `PHPUnit\Framework\TestCase`(拿不到事务隔离和 DB 断言)。 ```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` 这个方法)。这两个调用都在基类里,测试作者不要自己调。 ### 2.2 造数据(fixture) 框架层(`TestCaseBase`)**不提供**业务 fixture 工厂(如 `createUser` / `createOrder`)。各衍生项目在自己的 `app\common\test\TestCase` 里实现业务工厂方法。工厂方法应: - 运行在 `setUp` 事务内,`tearDown` 自动回滚,无需手动清理。 - 默认用 `Model::create()`;遇到表无 `update_time` 列但模型继承 `TimeModel` 时,回退 `Db::name()->insertGetId()`(见规则第六节"Db::name vs Model")。 - 造软删数据用 `Db::name($table)->where('id', $id)->update(['delete_time' => time()])`,绕开 `SoftDelete` trait。 ### 2.3 断言 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'] ?? '')); ``` ### 2.4 隔离(无需手动清理) 事务回滚是 PRIMARY 隔离手段。每个测试开事务、结束时回滚,数据不跨测试残留。不要在测试里手动 `DELETE` / `TRUNCATE` 清数据。 如果怀疑隔离失效(比如某个测试改了框架级单例、或跑了自动提交的 DDL),可以调用基类的 `assertIsolationWorks()` 做自验证(三步探针:先断言探针行不存在,插入,再断言可见;下一个测试的第一步如果失败就说明上一个测试没回滚干净)。极端降级方案是 `truncateTables(array $tables)`,但正常情况下用不到。 ## 3. 测试模板 ### 3.1 单元测试模板(纯函数 / 算法,无需 DB) 纯函数测试**不要**继承 `app\common\test\TestCase`(那会触发 setUp 连测试库、开事务,拖慢且强依赖容器)。直接继承 `PHPUnit\Framework\TestCase`: ```php assertSame('expected', $result); } } ``` 放进 `tests/Unit/`,namespace 用 `tests\Unit`。 ### 3.2 集成测试模板(service / DB 写入 / 状态流转) 集成测试继承 `app\common\test\TestCase`,用 fixture 造数据、调 service、断言: ```php 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`。 ## 4. 何时写哪种测试(决策表) 不是所有验证都要写 phpunit。选错工具会浪费时间。下表交叉引用了规则文件 `.agents/rules/ulthon-testing.md` 的决策树。 | 被测对象 | 用什么 | 放哪 | 原因 | |----------|--------|------|------| | 纯函数 / 算法 / 格式化 | phpunit 单元测试 | `tests/Unit/` | 无副作用,跑得快,不依赖容器 | | 致命约束 / 状态流转 service(余额扣减、状态机、软删历史、绑定约束) | phpunit 集成测试 | `tests/Integration/` | 要造 DB 前置、断言历史记录,事务回滚保证隔离 | | 控制器列表 / 表单 / 筛选 / 增删改查链路 | `tools:http:call` | 不写 phpunit | 模拟登录请求即可验证,写 phpunit 成本高收益低 | | 并发临界区 / 竞态(抢购抢锁等) | 独立并发脚本 | `runtime/agents/` 或临时脚本 | phpunit 单进程模拟不了真并发,要起多进程 | 一句话:**能复现就写测试,只是跑一次看返回就用 tools:http:call**。致命约束和状态流转必须用 phpunit 锁住,因为这类 bug 一旦上线就是事故级别。 ## 5. 如何设置测试环境 完整步骤(首次或重装时执行): 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 ``` ## 6. 如何验证回归保护(破坏 + 恢复法) 写完一个不变量级测试后,要确认它真的能抓住回归。光跑绿不够,因为一个永远绿的测试可能是空壳。用"破坏 + 恢复法"五步验证: 1. **基线绿**:先跑测试,确认全绿。这是起点。 2. **临时破坏**:把生产代码里被锁的逻辑临时改错。例如把"余额不能扣成负数"的校验临时注释掉,或把"拒绝重复绑定"改成"允许"。 3. **跑测试变红**:再跑测试,相关用例应该失败。如果这步还是绿,说明测试没锁住这个约束,白写了,回去补用例。 4. **回退代码**:`git checkout -- app/common/service/XxxService.php`(或对应文件),恢复生产代码原状。 5. **恢复绿**:再跑测试,确认全绿。证明破坏是测试抓到的,不是环境问题。 这五步走完,这个测试的回归保护才算真的成立。之后任何人改这段代码,CI 会替你拦住。 ## 相关文件 | 文件 | 作用 | |------|------| | `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` | 隔离机制冒烟测试(探针回滚 + 拒绝非测试库) | 交叉参考:测试类型决策与设计哲学见 `.agents/rules/ulthon-testing.md`(规则文件);控制器联调验证见 [ulthon-tools-http-call](./ulthon-tools-http-call/SKILL.md);Base/App 架构分层见 [ulthon-base-app-architecture](./ulthon-base-app-architecture/SKILL.md)。