mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 12:45:32 +08:00
- AGENTS.md 代码分层铁律精简为入口摘要,链接指向技能详情 - 合并 ulthon-timer-multi-node 规则到 ulthon-timer 技能(多节点协调章节) - 合并 ulthon-database-design 规则到 ulthon-scheme-definition 技能(含字段约定、组件类型) - 合并 ulthon-testing 规则到 ulthon-testing 技能(含设计哲学、决策树、测试约束) - rules-manager 边界原则从规则/技能二分改为按主题单一文档 - AGENTS.md 通用基础规范中表结构规范链接改向技能 - 工作流索引中三个技能描述扩充(明确承载原规则内容)
409 lines
22 KiB
Markdown
409 lines
22 KiB
Markdown
---
|
||
name: "ulthon-testing"
|
||
description: "框架测试完整指南:设计哲学(控制器中心主义、按需 service)、决策依据(4 层模型、该不该抽 service)、测试约束(专用测试库、事务回滚)、如何运行与编写测试、环境设置、回归保护验证。"
|
||
---
|
||
|
||
# 测试工作流(设计 + 操作)
|
||
|
||
本技能是框架级(`ulthon-` 前缀)的测试完整指南。涵盖:为什么这样设计、什么时候该写测试、必须遵守的约束、具体怎么运行和编写测试。
|
||
|
||
测试栈基线:PHPUnit 10.5.x(`composer.json` 里 `phpunit/phpunit: ^10`),ThinkPHP 8 容器,参数化测试库(复用 .env 连接,database = env+'_test'),事务回滚做隔离。
|
||
|
||
## 何时调用
|
||
|
||
- 第一次在这个项目跑测试,不知道怎么准备环境
|
||
- 要给一段状态流转、致命约束、软删除相关的 service 逻辑加回归测试
|
||
- 拿到一个 bug,想先用测试锁住复现路径再修
|
||
- 不确定该写 phpunit 还是直接用 `tools:http:call`
|
||
- 判断"该不该抽 service"、"这段逻辑要不要写测试"
|
||
|
||
## 设计哲学
|
||
|
||
防止后人或 AI 把框架的刻意设计误判为缺陷,这里把四条取向讲透。回答两个问题:(1) 框架里那么多业务逻辑没写单元测试,是不是不规范?(2) 哪些逻辑必须抽 service 并锁测试?回答之前先理解框架的设计取向,否则会把"设计如此"误判成"技术债"去重构,反而制造破坏。
|
||
|
||
### 控制器中心主义
|
||
|
||
业务逻辑写在控制器里,是设计如此,不是不规范。
|
||
|
||
框架侧重控制器。控制器等于接口等于页面,业务入口、参数校验、结果反馈都在这里完成。一个控制器方法同时承担"渲染页面"和"返回 JSON"两种职责(见下文"页面接口同体"),所以把业务流程写在控制器里,流程跟入口是同一段代码,改起来最直接,调试链路最短。
|
||
|
||
不要因为"控制器太胖"就机械地拆 service。控制器胖是因为业务就发生在这里,把业务搬到 service 只是把同样的代码换个文件,还多了一层跳转成本。
|
||
|
||
### 页面接口同体
|
||
|
||
一套控制器代码同时服务三端:后台管理页、用户端接口、小程序接口。消除"接口分叉"是这套机制的核心价值。
|
||
|
||
严禁拆成 `ApiController` + `PageController` 两套。一旦拆开,三端逻辑就会各自漂移,今天改了页面端忘了同步接口端,明天接口端加了字段页面端没跟上,最终三端行为不一致。同体机制的价值有五条:
|
||
|
||
1. **一套逻辑不会分叉**:同一方法同一代码路径,三端拿到的结果数学上完全一致
|
||
2. **接口测试 ROI 放大**:测一条路径等于同时覆盖页面和接口,不用维护两套测试
|
||
3. **改一处生效三端**:业务变更只改一个方法,三端同步生效,没有"忘了同步"的窗口
|
||
4. **框架机制保证三模式自动切换**:同体不是手写 if-else 判断请求类型,框架按请求特征自动选择渲染或 JSON 响应,开发者无感
|
||
5. **这不是图省事,是消除分叉风险**:同体是架构决策,不是偷懒。拆开的代价(三端不一致的线上事故)远大于合并的代价(一个方法稍长)
|
||
|
||
详细机制见技能 [ulthon-page-api-dual-mode](./ulthon-page-api-dual-mode/SKILL.md)。
|
||
|
||
### 按需 service
|
||
|
||
框架对 service 的规范是:**多应用复用才放 `app/common/service/`**。
|
||
|
||
大多数应用业务不需要封装 service。把"只用一次的业务流程"硬抽成 service,只是把代码搬离控制器,没有复用收益,反而增加了跳转和传参成本。
|
||
|
||
只有两种情况才值得抽 service:
|
||
|
||
- **(a) 产品工具型逻辑**:算法、编号解析、格式转换这类"输入输出确定、不变、多处用"的逻辑。例如编号生成器、二维码解析器。它们是工具,不是业务流程
|
||
- **(b) 不可逆且致命的约束**:错了会出真实事故的校验逻辑。例如余额扣减的"不能扣成负数"、绑定约束的"不能重复绑定"、状态流转的"不可逆转换"。这种逻辑必须从控制器流程里独立出来,单独测、单独审、单独防回归
|
||
|
||
### 应用业务 vs 产品工具
|
||
|
||
这两类代码的价值取向完全相反,不能用同一套规范套。
|
||
|
||
- **应用业务**追求快速响应变化。业务规则经常变(今天满减门槛 100 元,明天改 80 元),逻辑写死在控制器里反而最好改。过度封装 service 和写单元测试,会让"改一个数字"变成"改 service + 改测试 + 改 mock 数据"三件事,成为响应变化的障碍
|
||
- **产品工具**追求绝对正确性。sqlite 管理、网盘协议、解析器这类工具,输入输出契约一旦确定就不该变,错了就是工具本身坏了。这里 service 和单元测试很有价值,因为不变量是死的,测试能长期守护
|
||
|
||
判断一段逻辑属于哪类:问"它会变吗,还是它该永远对?"。会变的是应用业务,该永远对的是产品工具。
|
||
|
||
## 该不该抽 service / 该不该写测试(决策树)
|
||
|
||
判断标准有且只有两个维度:**错了的后果** + **是否多处复用**。注意,判断标准不是"会不会变"。业务会变不代表不该封装,余额规则再怎么变,"不能扣成负数""不能重复扣"这些不变量是死的,值得锁住。
|
||
|
||
```
|
||
这段逻辑错了会怎样?
|
||
├─ 后果不可承受(钱/医疗/不可逆/法律)→ 必须抽 service + 写测试
|
||
│ 余额扣减、状态流转、绑定约束、支付回调、库存扣减...
|
||
├─ 后果可承受,但逻辑被多处复用 → 抽 service(DRY),测试看情况
|
||
│ 消息发送、文件上传、数据导出、编号生成...
|
||
└─ 后果可承受,且只用一次 → 写控制器里,http:call 验证就够
|
||
字段名、展示顺序、列表筛选、表单校验、页面跳转...
|
||
```
|
||
|
||
三条分支的落地:
|
||
|
||
- **后果不可承受**:抽到 `app/common/service/`,配 PHPUnit 集成测试。测试要锁死不变量,不只是覆盖当前行为
|
||
- **后果可承受 + 多处复用**:抽 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 很低
|
||
- **核心 service 层**才用 PHPUnit 集成测试,且必须继承 `app\common\test\TestCase`(见"测试约束"),跑在真实测试库 + 事务回滚隔离里
|
||
- **纯函数层**可以用裸 `assert`(一个 `php` 脚本跑完),不一定要进 PHPUnit 套件,除非逻辑足够复杂值得长期守护
|
||
|
||
## service 层测试边界
|
||
|
||
"多应用复用才放 `app/common/service/`"和"可测性需求"之间有张力:有些逻辑只用一次,但错了后果不可承受。
|
||
|
||
何时破例:**致命约束型 service 值得破例抽出来测**。即使一段逻辑只在当前应用的某个流程里用一次,只要它错了是资金事故/法律事故/安全事故(决策树第一分支),就值得抽成 service 配测试。理由是测试需要稳定的入口和可复现的 fixture,控制器方法很难提供这两个条件(请求上下文、事务、鉴权状态都会干扰),service 的纯函数式签名天生适合测试。
|
||
|
||
反过来说,后果可承受的逻辑(决策树第二、三分支)不要为了"可测"硬抽 service。留在控制器里用 `tools:http:call` 验证,更符合应用业务的响应变化需求。
|
||
|
||
典型破例场景:余额扣减的"不能扣成负数"、唯一性绑定约束、不可逆状态流转、支付回调验签、库存扣减的"不能超卖"。
|
||
|
||
## 测试约束(铁律)
|
||
|
||
以下约束不可协商。
|
||
|
||
### 强制使用专用测试库
|
||
|
||
- 所有 PHPUnit 测试**必须**连专用测试库(库名 = `.env` 中 `DATABASE` 值 + `_test` 后缀,如 `ulthon_admin` → `ulthon_admin_test`),**禁止**连开发/生产库
|
||
- 连接参数(host/port/user/pass/charset/prefix)从 `.env` 读取,复用开发库同实例,单机 MySQL 只需建一个 `_test` 库
|
||
- 覆盖实现位于 `tests/bootstrap.php`:在 `App::initialize()` 之后通过 config 级覆盖 `connections.main`(**不能用** `putenv` / `$env:VAR` / bash 前缀变量,因为 ThinkPHP 的 Env 加载 `.env`,`.env` 值优先于 OS 环境变量)
|
||
|
||
### 第一道安全闸门
|
||
|
||
`base\common\test\TestCaseBase::setUp()` 的第一步是 `assertTestDatabase()`:读当前连接的库名,若不含子串 `test`(大小写不敏感),立即 `fail()` 中止测试。这保证即使 `tests/bootstrap.php` 的 config 覆盖写错、或 `.env` 被误改指向生产库,测试也绝不会对生产库写入任何数据。
|
||
|
||
### 业务测试必须继承 app 层入口
|
||
|
||
业务测试用例**必须**继承 `app\common\test\TestCase`(继承自内核 `base\common\test\TestCaseBase`)。**不要**直接继承 `TestCaseBase`(那是内核层),也不要直接继承 `PHPUnit\Framework\TestCase`(拿不到 fixture 工厂和事务隔离)。
|
||
|
||
### 隔离手段 = 事务回滚(禁止手动清表)
|
||
|
||
- `TestCaseBase::setUp()` 开启事务,`tearDown()` 自动 `Db::rollback()` 撤销本测试的一切 DB 写入
|
||
- **禁止**在测试里手动 `DELETE` / `TRUNCATE` 清数据
|
||
- 怀疑隔离失效时可调基类的 `assertIsolationWorks()` 自验证;极端降级方案 `truncateTables(array $tables)` 正常情况下用不到
|
||
|
||
### 业务 fixture 工厂由项目自实现
|
||
|
||
- 框架层(`TestCaseBase`)只提供通用基建:事务回滚隔离、DB 断言(`assertDatabaseHas` / `assertDatabaseMissing`)、隔离探针、Plan B(`truncateTables`)
|
||
- **不提供**业务 fixture 工厂(如 `createUser` / `createOrder` 等)
|
||
- 各衍生项目在自己的 `app/common/test/TestCase` 中实现业务工厂方法,工厂方法运行在 `setUp` 事务内,`tearDown` 自动回滚
|
||
|
||
## 如何运行测试
|
||
|
||
### 首次准备(建测试库 + 建表,只做一次)
|
||
|
||
测试库复用开发库同实例的连接参数(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;
|
||
```
|
||
|
||
第二步,建表 + 种子(测试库已建后执行):
|
||
|
||
```bash
|
||
php tests/setup_test_db.php
|
||
```
|
||
|
||
这个脚本会在同进程内依次跑 `migrate:run`(系统表)、`scheme:sync`(业务表)、`seed:run`(参考数据),并自查 `information_schema`。成功退出码 0。脚本幂等,可安全重跑。
|
||
|
||
### 日常运行
|
||
|
||
全量跑(所有测试套件):
|
||
|
||
```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 下用正斜杠。
|
||
|
||
## 如何编写新测试
|
||
|
||
### 继承应用层入口
|
||
|
||
集成测试一律继承 `app\common\test\TestCase`:
|
||
|
||
```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` 这个方法)。这两个调用都在基类里,测试作者不要自己调。
|
||
|
||
### 造数据(fixture)
|
||
|
||
各衍生项目在自己的 `app\common\test\TestCase` 里实现业务工厂方法。工厂方法应:
|
||
|
||
- 运行在 `setUp` 事务内,`tearDown` 自动回滚,无需手动清理
|
||
- 默认用 `Model::create()`;遇到表无 `update_time` 列但模型继承 `TimeModel` 时,回退 `Db::name()->insertGetId()`(绕开 ORM 与表结构不匹配)
|
||
- 造软删数据用 `Db::name($table)->where('id', $id)->update(['delete_time' => time()])`,绕开 `SoftDelete` trait 的拦截复杂度
|
||
|
||
### 断言
|
||
|
||
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'] ?? ''));
|
||
```
|
||
|
||
### 隔离(无需手动清理)
|
||
|
||
事务回滚是 PRIMARY 隔离手段。每个测试开事务、结束时回滚,数据不跨测试残留。
|
||
|
||
如果怀疑隔离失效(比如某个测试改了框架级单例、或跑了自动提交的 DDL),可以调用基类的 `assertIsolationWorks()` 做自验证(三步探针:先断言探针行不存在,插入,再断言可见;下一个测试的第一步如果失败就说明上一个测试没回滚干净)。
|
||
|
||
## 测试模板
|
||
|
||
### 单元测试模板(纯函数 / 算法,无需 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`。
|
||
|
||
### 集成测试模板(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`。
|
||
|
||
## 测试类型速查
|
||
|
||
被测对象 → 工具/路径 的快速映射。选对工具后跳到对应小节查看详细操作。
|
||
|
||
| 被测对象 | 用什么 | 放哪 |
|
||
|----------|--------|------|
|
||
| 纯函数 / 算法 / 格式化 | phpunit 单元测试 | `tests/Unit/` |
|
||
| 致命约束 / 状态流转 service(余额扣减、状态机、软删历史、绑定约束) | phpunit 集成测试 | `tests/Integration/` |
|
||
| 控制器列表 / 表单 / 筛选 / 增删改查链路 | `tools:http:call` | 不写 phpunit |
|
||
| 并发临界区 / 竞态(抢购抢锁等) | 独立并发脚本 | `runtime/agents/` 或临时脚本 |
|
||
|
||
## 如何设置测试环境
|
||
|
||
完整步骤(首次或重装时执行):
|
||
|
||
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
|
||
```
|
||
|
||
## 如何验证回归保护(破坏 + 恢复法)
|
||
|
||
写完一个不变量级测试后,要确认它真的能抓住回归。光跑绿不够,因为一个永远绿的测试可能是空壳。用"破坏 + 恢复法"五步验证:
|
||
|
||
1. **基线绿**:先跑测试,确认全绿。这是起点
|
||
2. **临时破坏**:把生产代码里被锁的逻辑临时改错。例如把"余额不能扣成负数"的校验临时注释掉,或把"拒绝重复绑定"改成"允许"
|
||
3. **跑测试变红**:再跑测试,相关用例应该失败。如果这步还是绿,说明测试没锁住这个约束,白写了,回去补用例
|
||
4. **回退代码**:`git checkout -- app/common/service/XxxService.php`(或对应文件),恢复生产代码原状
|
||
5. **恢复绿**:再跑测试,确认全绿。证明破坏是测试抓到的,不是环境问题
|
||
|
||
这五步走完,这个测试的回归保护才算真的成立。之后任何人改这段代码,CI 会替你拦住。
|
||
|
||
## 自查清单
|
||
|
||
写测试或动 service 前,对照自查:
|
||
|
||
- [ ] 这段逻辑错了后果可承受吗?("设计哲学" + "决策树")后果可承受且只用一次,留在控制器 + `tools:http:call`,不要硬抽 service
|
||
- [ ] 如果错了不可承受,抽成 service 了吗?配 PHPUnit 集成测试了吗?(4 层模型"核心 service"层)
|
||
- [ ] 测试继承 `app\common\test\TestCase` 了吗?连的是 `_test` 库吗?(测试约束)
|
||
- [ ] fixture 用工厂方法了吗?没有手动 truncate 吧?(如何编写新测试)
|
||
- [ ] 致命约束的不变量用测试锁死了吗?(不只是覆盖当前行为,要锁死"不能怎样"的约束)
|
||
|
||
## 相关文件
|
||
|
||
| 文件 | 作用 |
|
||
|------|------|
|
||
| `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` | 隔离机制冒烟测试(探针回滚 + 拒绝非测试库) |
|
||
|
||
交叉参考:控制器联调验证见 [ulthon-tools-http-call](./ulthon-tools-http-call/SKILL.md);Base/App 架构分层见 [ulthon-base-app-architecture](./ulthon-base-app-architecture/SKILL.md)。
|