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

178 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.

# 测试设计哲学与边界规范
> 来源框架内置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](../skills/ulthon-page-api-dual-mode/SKILL.md)。
### 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 | `.env``DATABASE` 值 + `_test` 后缀(如 `ulthon_admin``ulthon_admin_test` |
这样单机 MySQL 只需建一个同实例的 `_test` 库,无需额外 Docker 容器。覆盖实现在 `tests/bootstrap.php`config 级覆盖 `connections.main`)。
### 第一道安全闸门
`base\common\test\TestCaseBase::setUp()` 的第一步是 `assertTestDatabase()`:读当前连接的库名,若不含子串 `test`(大小写不敏感),立即 `fail()` 中止测试。这保证即使 `tests/bootstrap.php` 的 config 覆盖写错、或 `.env` 被误改指向生产库,测试也绝不会对生产库写入任何数据。
`ulthon_admin_test``test`,放行;远程生产库(不含 `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 8`Config::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` 列,但模型继承 `TimeModel``autoWriteTimestamp=true``updateTime='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 B`truncateTables`,事务回滚失效时的降级)
**不提供**业务 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 吧?(第六节)
- [ ] 致命约束的不变量用测试锁死了吗?(不只是覆盖当前行为,要锁死"不能怎样"的约束)