Files
ulthon_admin/.agents/rules/ulthon-testing.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

测试设计哲学与边界规范

来源框架内置ulthon- 作用域所有测试编写、service 抽象、控制器逻辑组织 触发条件:判断"该不该抽 service / 该不该写测试"、写 PHPUnit、动测试库时加载

本规则回答两个问题:(1) 框架里那么多业务逻辑没写单元测试,是不是不规范?(2) 哪些逻辑必须抽 service 并锁测试?回答之前先理解框架的设计取向,否则会把"设计如此"误判成"技术债"去重构,反而制造破坏。

一、核心设计哲学

防止后人或 AI 把框架的刻意设计误判为缺陷,这里把四条取向讲透。

1.1 控制器中心主义

业务逻辑写在控制器里,是设计如此,不是不规范。

框架侧重控制器。控制器等于接口等于页面,业务入口、参数校验、结果反馈都在这里完成。一个控制器方法同时承担"渲染页面"和"返回 JSON"两种职责(见 1.2),所以把业务流程写在控制器里,流程跟入口是同一段代码,改起来最直接,调试链路最短。

不要因为"控制器太胖"就机械地拆 service。控制器胖是因为业务就发生在这里把业务搬到 service 只是把同样的代码换个文件,还多了一层跳转成本。

1.2 页面接口同体

一套控制器代码同时服务三端:后台管理页、用户端接口、小程序接口。消除"接口分叉"是这套机制的核心价值。

严禁拆成 ApiController + PageController 两套。一旦拆开,三端逻辑就会各自漂移,今天改了页面端忘了同步接口端,明天接口端加了字段页面端没跟上,最终三端行为不一致。同体机制的价值有五条:

  1. 一套逻辑不会分叉:同一方法同一代码路径,三端拿到的结果数学上完全一致。
  2. 接口测试 ROI 放大:测一条路径等于同时覆盖页面和接口,不用维护两套测试。
  3. 改一处生效三端:业务变更只改一个方法,三端同步生效,没有"忘了同步"的窗口。
  4. 框架机制保证三模式自动切换:同体不是手写 if-else 判断请求类型,框架按请求特征自动选择渲染或 JSON 响应,开发者无感。
  5. 这不是图省事,是消除分叉风险:同体是架构决策,不是偷懒。拆开的代价(三端不一致的线上事故)远大于合并的代价(一个方法稍长)。

详细机制见技能 ulthon-page-api-dual-mode

1.3 按需 service

框架对 service 的规范是:多应用复用才放 app/common/service/

大多数应用业务不需要封装 service。把"只用一次的业务流程"硬抽成 service只是把代码搬离控制器没有复用收益反而增加了跳转和传参成本。

只有两种情况才值得抽 service

  • (a) 产品工具型逻辑:算法、编号解析、格式转换这类"输入输出确定、不变、多处用"的逻辑。例如编号生成器、二维码解析器。它们是工具,不是业务流程。
  • (b) 不可逆且致命的约束:错了会出真实事故的校验逻辑。例如余额扣减的"不能扣成负数"、绑定约束的"不能重复绑定"、状态流转的"不可逆转换"。这种逻辑必须从控制器流程里独立出来,单独测、单独审、单独防回归。

1.4 做应用业务 ≠ 开发产品工具

这两类代码的价值取向完全相反,不能用同一套规范套。

  • 应用业务追求快速响应变化。业务规则经常变(今天满减门槛 100 元,明天改 80 元),逻辑写死在控制器里反而最好改。过度封装 service 和写单元测试,会让"改一个数字"变成"改 service + 改测试 + 改 mock 数据"三件事,成为响应变化的障碍。
  • 产品工具追求绝对正确性。sqlite 管理、网盘协议、解析器这类工具,输入输出契约一旦确定就不该变,错了就是工具本身坏了。这里 service 和单元测试很有价值,因为不变量是死的,测试能长期守护。

判断一段逻辑属于哪类:问"它会变吗,还是它该永远对?"。会变的是应用业务,该永远对的是产品工具。

二、判断决策树:该不该抽 service / 该不该写测试

判断标准有且只有两个维度:错了的后果 + 是否多处复用。注意,判断标准不是"会不会变"。业务会变不代表不该封装,余额规则再怎么变,"不能扣成负数""不能重复扣"这些不变量是死的,值得锁住。

这段逻辑错了会怎样?
├─ 后果不可承受(钱/医疗/不可逆/法律)→ 必须抽 service + 写测试
│     余额扣减、状态流转、绑定约束、支付回调、库存扣减...
├─ 后果可承受,但逻辑被多处复用 → 抽 serviceDRY测试看情况
│     消息发送、文件上传、数据导出、编号生成...
└─ 后果可承受,且只用一次 → 写控制器里http:call 验证就够
      字段名、展示顺序、列表筛选、表单校验、页面跳转...

三条分支的落地:

  • 后果不可承受:抽到 app/common/service/,配 PHPUnit 集成测试(见第三节"核心 service"层)。测试要锁死不变量,不只是覆盖当前行为。
  • 后果可承受 + 多处复用:抽 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 很低。这与 AGENTS.md"调试与验证优先使用框架内置命令行工具"一致。
  • 核心 service 层才用 PHPUnit 集成测试,且必须继承 app\common\test\TestCase(见第五、六节),跑在真实测试库 + 事务回滚隔离里。
  • 纯函数层可以用裸 assert(一个 php 脚本跑完),不一定要进 PHPUnit 套件,除非逻辑足够复杂值得长期守护。

四、service 层测试边界

框架规范"多应用复用才放 app/common/service/"(第一节 1.3)和"可测性需求"之间有张力:有些逻辑只用一次,但错了后果不可承受。

何时破例:致命约束型 service 值得破例抽出来测。即使一段逻辑只在当前应用的某个流程里用一次,只要它错了是资金事故/法律事故/安全事故(决策树第一分支),就值得抽成 service 配测试。理由是测试需要稳定的入口和可复现的 fixture控制器方法很难提供这两个条件请求上下文、事务、鉴权状态都会干扰service 的纯函数式签名天生适合测试。

反过来说,后果可承受的逻辑(决策树第二、三分支)不要为了"可测"硬抽 service。留在控制器里用 tools:http:call 验证,更符合应用业务的响应变化需求。

典型破例场景:余额扣减的"不能扣成负数"、唯一性绑定约束、不可逆状态流转、支付回调验签、库存扣减的"不能超卖"。这些逻辑错了就是真金白银或合规事故,抽出来 + 写 PHPUnit 集成测试锁死不变量。

五、测试数据库安全规范

强制使用专用测试库

所有 PHPUnit 测试必须连专用测试库,禁止.env 配置的开发/生产库。

测试库连接策略(参数化,复用开发库同实例):

host / port / username / password / charset / prefix 从 .env 读取(复用开发库同实例的连接参数)
database .envDATABASE 值 + _test 后缀(如 ulthon_adminulthon_admin_test

这样单机 MySQL 只需建一个同实例的 _test 库,无需额外 Docker 容器。覆盖实现在 tests/bootstrap.phpconfig 级覆盖 connections.main)。

第一道安全闸门

base\common\test\TestCaseBase::setUp() 的第一步是 assertTestDatabase():读当前连接的库名,若不含子串 test(大小写不敏感),立即 fail() 中止测试。这保证即使 tests/bootstrap.php 的 config 覆盖写错、或 .env 被误改指向生产库,测试也绝不会对生产库写入任何数据。

ulthon_admin_testtest,放行;远程生产库(不含 test)拦截。

测试库初始化

tests/setup_test_db.php 初始化表结构(参数化,复用 .env 连接 + database=_test)。脚本内部跑 migrate:run(系统表)+ scheme:sync --force-force(业务表)+ seed:run(参考数据),幂等可重复执行。详见 tests/README.md

为什么用 config 级覆盖而不是环境变量

ThinkPHP 的 Env 组件加载 .env,且 .env 值【优先于】OS 环境变量(getenv 仅作为 .env 中不存在键的 fallback。因此 putenv('DATABASE=...')$env:DATABASE=...、bash 前缀变量都无法覆盖 .env 里已存在的连接键。唯一可靠覆盖点是 config 级:在 App::initialize() 之后直接改写 config/database.php 解析出的连接数组。

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

六、fixture 使用规范(通用原则)

继承应用层入口

业务测试用例继承 app\common\test\TestCase(继承自内核 base\common\test\TestCaseBase)。不要直接继承 TestCaseBase(那是内核层),也不要直接继承 PHPUnit\Framework\TestCase(拿不到 fixture 工厂和事务隔离)。

事务自动回滚(无需手动清理)

TestCaseBase::setUp() 开启事务,tearDown() 自动 Db::rollback() 撤销本测试的一切 DB 写入。无需手动 truncate 或 delete

事务 APIthink-orm 3.0,实测确认):Db::startTrans() 开启 / Db::rollback() 回滚 / Db::commit() 提交。注意没有 rollbackTrans / commitTrans 这两个方法。

Db::name vs Model 选择原则

造 fixture 数据时,默认用 Model::create()(走模型,享受自动时间戳等特性)。但遇到这种情况要回退 Db::name()

  • 表的 Scheme 没有 update_time 列,但模型继承 TimeModelautoWriteTimestamp=trueupdateTime='update_time'),用 Model::create 会因 fields_strict=true 写不存在的列而抛错。
  • 此时走 Db::name($table)->insertGetId($data) 显式只写表里实际存在的列,再 Model::find($id) 回查返回模型实例保持类型一致。

这是"model 不适用就回退 Db::name"的典型场景,不是偷懒,是绕开 ORM 与表结构不匹配的必要手段。

软删除 fixture

需要造软删数据时,用 Db::name($table)->where('id', $id)->update(['delete_time' => time()]) 绕开 SoftDelete trait 的拦截复杂度对任意模型类型确定生效。不要试图通过模型实例触发软删trait 的拦截逻辑在不同模型配置下行为不一)。

业务 fixture 工厂由各项目自行实现

框架层(TestCaseBase)只提供通用基建:

  • 事务回滚隔离(setUp / tearDown
  • DB 断言(assertDatabaseHas / assertDatabaseMissing
  • 隔离探针(assertIsolationWorks
  • Plan BtruncateTables,事务回滚失效时的降级)

不提供业务 fixture 工厂(如 createUser / createOrder 等)。各衍生项目在自己的 app/common/test/TestCase(继承 TestCaseBase)中实现业务工厂方法。工厂方法应运行在 setUp 事务内,tearDown 自动回滚,无需手动清理。

七、自查清单

写测试或动 service 前,对照本规则自查:

  • 这段逻辑错了后果可承受吗?(第一节 1.4 + 第二节决策树)后果可承受且只用一次,留在控制器 + tools:http:call,不要硬抽 service。
  • 如果错了不可承受,抽成 service 了吗?配 PHPUnit 集成测试了吗?(第三节"核心 service"层)
  • 测试继承 app\common\test\TestCase 了吗?连的是 _test 库吗?(第五节)
  • fixture 用工厂方法了吗?没有手动 truncate 吧?(第六节)
  • 致命约束的不变量用测试锁死了吗?(不只是覆盖当前行为,要锁死"不能怎样"的约束)