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

13 KiB
Raw Blame History

name, description
name description
ulthon-testing 框架测试工作流:如何运行测试、编写新测试(继承/fixture/事务回滚)、选择测试类型、设置测试环境、验证回归保护。需要跑 phpunit 或给致命约束/状态流转逻辑加测试时调用。

测试工作流(运行 + 编写 + 决策)

本技能是框架级(ulthon- 前缀)的测试入门指南。读完这一篇,新开发者或 AI 就能:把测试跑起来、照模板写一个新测试、选对测试工具、设置测试环境。

测试栈基线PHPUnit 10.5.xcomposer.jsonphpunit/phpunit: ^10ThinkPHP 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 连接):

CREATE DATABASE IF NOT EXISTS ulthon_admin_test
  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

(库名 = .env 的 DATABASE 值 + _test,如 ulthon_adminulthon_admin_test

第二步,建表 + 种子(测试库已建后执行):

php tests/setup_test_db.php

这个脚本会在同进程内依次跑 migrate:run(系统表)、scheme:sync(业务表)、seed:run(参考数据),并自查 information_schema。成功退出码 0。脚本幂等可安全重跑。

1.2 日常运行

全量跑(所有测试套件):

# Linux / macOS / Git Bash
vendor/bin/phpunit

# Windows PowerShell / cmd
vendor\bin\phpunit

跑单个测试文件:

vendor/bin/phpunit tests/Feature/IsolationSmokeTest.php

只跑某个测试方法(--filter 匹配方法名片段):

vendor/bin/phpunit --filter test_isolation

按套件跑(phpunit.xml.dist 定义了 testsuite具体名称以该文件为准

vendor/bin/phpunit --testsuite feature

注意Windows 下路径用反斜杠 vendor\bin\phpunitGit Bash / WSL 下用正斜杠。

2. 如何编写新测试

2.1 继承应用层入口

集成测试一律继承 app\common\test\TestCase。不要直接继承 base\common\test\TestCaseBase(那是内核层),也不要直接继承 PHPUnit\Framework\TestCase(拿不到事务隔离和 DB 断言)。

namespace tests\Integration;

use app\common\test\TestCase;

class XxxTest extends TestCase
{
    // ...
}

继承之后,基类自动帮你做两件事,你完全不用操心:

  • setUp:先校验当前连的是测试库(库名含 test 才放行,拒绝连生产库),再 Db::startTrans() 开事务。
  • tearDownDb::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() 一致):

$this->assertDatabaseHas('system_admin', ['username' => 'admin']);
$this->assertDatabaseMissing('system_admin', ['username' => 'deleted_user']);

断言失败时,建议在 message 里带上被测方法的诊断信号。例如 service 返回的 reason 文案是它唯一的诊断出口,拼进 message 能立刻看出是哪道校验拒绝了:

$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

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

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 installphpunit/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.phptests/setup_test_db.php 都用这个模式。

注意一个 think 8 的坑:Config::set(array, $name) 的第二参数只支持单段一级名,不像 Config::get 那样解析点号路径。正确写法是把整段 database 配置 pull 出来,原地改 connections.main,再 Config::set($dbConfig, 'database') 写回。

彻底重置测试库:

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-callBase/App 架构分层见 ulthon-base-app-architecture