Files
ulthon_admin/.agents/skills/ulthon-testing/SKILL.md
augushong 37cb8291f8 feat(test): 引入测试规则文档与参数化测试库初始化脚本
对齐 wkbox 衍生项目的测试规则体系,剥离业务特定内容后移植到框架:

- 规则 .agents/rules/ulthon-testing.md(设计哲学/决策树/4层模型/安全规范/fixture原则)
- 技能 .agents/skills/ulthon-testing(运行/编写/模板/决策/回归验证)
- 参数化建库脚本 tests/setup_test_db.{php,ps1,sh} + README
- AGENTS.md 索引补充

与 wkbox 差异:测试库参数化(复用 .env 连接,database=env+'_test'),不硬编码 Docker;删去 canBind Oracle 业务约束与具体 fixture 工厂,业务工厂由衍生项目自行实现。
2026-07-18 23:56:32 +08:00

279 lines
13 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: "框架测试工作流:如何运行测试、编写新测试(继承/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
<?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`
### 3.2 集成测试模板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`
## 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)。