关于 test-design
test-design 是指导测试用例设计的 Agent skill,面向编写或审查单元与集成测试的开发者。它聚焦用户可观察的行为契约,并以变异检查验证测试是否能发现回归。
主要功能
- 01围绕行为契约设计测试
- 02按等价类与边界选取用例
- 03断言返回值、状态和副作用
- 04限制模拟对象并优先使用 Fake
- 05通过变异检查验证测试有效性
适用场景
- 01为新功能规划单元或集成测试
- 02审查测试是否只覆盖实现细节
- 03选择边界值和脏输入用例
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ gusen1453/ creed --skill test-design安装后在 Claude Code 中输入/test-design确认已加载。
详细介绍
测试设计
**输出语言:**使用用户自己的语言回答并编写产物(与用户的消息保持一致,而不是与仓库的历史保持一致)。
概述
回答**“测试应该输入什么、断言什么,以及模拟什么”**。不涉及红—绿—重构节奏(那是 TDD)。
核心原则:测试保护的是用户/调用方可观察到的行为契约,而不是实现细节,更不是覆盖率数字。 好的测试是对契约可证伪的规范;坏的测试只是用另一种语法复述代码。
**必备配套:**关于先写测试的节奏,请参阅 tdd。关于模拟对象增多时的模块边界,请参阅 solid。此技能只规范用例和断言的设计质量。
适用场景
- 为一段代码添加或修改单元/集成测试
- 审查他人的测试,判断它们是否有效,还是流于形式
- 决定“这段代码值得测试吗?”或“该用单元测试还是集成测试?”
- 因覆盖率压力、截止日期紧迫或沉没成本而准备编写无效测试
**不适用场景:**仅运行测试/查看失败 → test-runner。仅遵循测试优先的节奏 → tdd。
铁律(不可妥协)
Break one line of production code and the test protecting it MUST turn red.
If it can't, the test is formalism — delete it and rewrite.
写完每个测试后,在脑中(或实际)破坏它所保护的逻辑,并确认测试会变红。 违反规则字面要求,就是违背规则精神——“我的覆盖率没问题”和“我遵循了规则精神”都不算数。
工作流(调用时)
宣布“正在使用 test-design 来……”,然后为每个步骤创建一个待办事项:
- 判断是否值得测试(§1):自身逻辑复杂 → 单元测试;依赖关系接线 → 集成测试。不值得 → 不要写。
- 列出行为清单(§2):只依据契约——“当……时,应该……”,包括没有专门分支的情况(例如“不要删除我的合法内容”)。每个用例都要用面向读者的语言写明其目标/条件/参数(见 §2)。
- 选择代表点(§3):等价类/边界/决策表/状态转换/脏输入——每类选一个代表,不要添加同质的重复用例。
- 编写断言(§4):只断言返回值/可观察状态/副作用边界;范围要窄且明确;手动编写
expected。 - 决定模拟对象(§5):只模拟外部世界;数量 ≤ 2-3;优先使用 Fake/Recording。
- 变异检查(铁律):破坏生产代码,确认每个测试都会变红。
- 检查合理化对照表和危险信号;如命中任何一项,立即停下并重新思考。
§1 是否值得测试(投资回报率,而非覆盖率)
判断标准:复杂性是在代码自身逻辑中,还是在依赖项之间的交互中?
- 自身逻辑(算法、纯计算、领域模型/状态机、分支繁多的策略/验证/降级逻辑)→ 单元测试,投资回报率最高。
- 依赖关系接线(CRUD、
A.get() → convert() → B.save()胶水代码)→ 集成测试。此类代码的风险恰恰存在于模拟对象取代真实依赖的地方(依赖项返回 null、超时、抛出异常、事务未提交);单元测试只证明“在一切顺利时它能运行”=毫无价值。
不要对胶水代码做单元测试;不要对算法做集成测试。
§2 根据行为列表查找用例
仅根据契约编写“当……时,应……”。绝不要统计源码中的 if。 列表 MUST 包含没有专门分支、但用户最关心的事项。
如果某个用例是“读源码时写出来的”,就删除并重写——它保护的是实现,而不是契约。
每个用例都必须让非作者看得懂。 在任何代码之前,用通俗的用户/产品语言说明以下三点:
- 目标 — 用一句非作者能理解的话说明该用例要证明什么(用户可见的结果,而非实现机制)。
- 条件 — 用例设置的状态(谁/哪些数据/刚刚发生了什么)。
- 参数 — 具体输入和预期的可观察结果(取值,以及为什么这个值值得关注——边界值、极端值、真实场景)。
如果一个用例的目标只有重读源码才能看懂,就没有达到要求。示例:
Case: re-import with the same file twice
- Goal: a re-run must not double-count rows (the bug users reported as "my totals doubled")
- Conditions: the file was already imported once; no other change
- Parameters: same file; expect the row count unchanged, and an idempotency marker set
§3 选择代表性取值的五种技巧
- 等价类划分:每个行为组选择一个代表值(同一组中的第二个值 = 重复)。
- 边界值:每个阈值都测试
boundary-1 / boundary / boundary+1(>=4必须测试“恰好 3”和“恰好 4”——只有这样才能发现>与>=的区别)。 - 决策表:当输出取决于多个条件时,枚举各种组合;必须包含“多个条件同时命中”;条件很多时使用两两组合测试。
- 状态转换:合法路径 + 非法转换(关闭后使用)+ 幂等性(关闭×2)+ 重复操作。
- 异常/恶意输入:空值、null、超大值、巨型 JSON、结构损坏(缺少闭合标签、嵌套)、类型错误。
§4 断言(正向做法)
仅断言以下三种可观察目标:① 返回值/抛出的错误(类型 + 消息 + 代码)② 可观察的状态变更 ③ 副作用边界(是否发送请求、发送到哪里、参数结构)。
- 范围窄且要求严格:多个精确的小断言 > 一个大型
toEqual(whole object)。 - 手写
expected:expected = input.replace(sameRegex)是在断言“正则表达式与自身相同”——永远通过。 - 不要触及实现细节:私有调用次数/内部分支,除非“次数/顺序”本身就是契约(例如,恰好重试 3 次、不得重复扣费)。
§5 Mock 规则
只替换缓慢/不稳定/有副作用/成本高昂的外部协作者。不要用它来绕过难以测试的设计。
| 应 Mock | 绝不要 Mock |
|---|---|
| 网络/HTTP/第三方 SDK/LLM、DB/FS/MQ、时钟、随机数生成器 | 被测单元、纯函数、值对象、模式、模块内的简单协作者 |
保真度顺序:Fake(内存中)> Recording/contract replay > Spy > Mock 框架。只断言契约结构:
// Good: assert the outward contract
expect(fetchMock).toHaveBeenCalledWith(
expect.objectContaining({ method: "POST", url: expect.stringContaining("/nodes") }),
)
// Bad: lock implementation detail, red on any refactor
expect(buildPayload).toHaveBeenCalledTimes(1)
§6 用户视角
从真实使用者的视角断言:终端用户看到结果;上游 API/Agent 看到字段和错误代码;运维人员能看到日志/状态是否有助于定位问题。
测试名称 = 角色 + 场景 + 预期(禁止使用 case 1/should work);从公共入口进入,不要深入私有函数。
§7 选择测试结构
逻辑密集型 → 金字塔(底层有大量单元测试);业务/CRUD → 菱形(中间有大量集成测试,单元测试仅用于工具函数和核心算法)。 集成测试能捕获单元测试无法发现的问题:事务提交、JSON 字段丢失、参数校验、真实依赖故障。
合理化借口表(写形式化内容前如何自欺欺人)
| 借口 | 现实 |
|---|---|
| “加个测试覆盖这个分支就行” | 覆盖率不是保护。问问“用户会遇到什么问题?”;没有风险 → 不要测试。 |
“用函数计算 expected 更准确” | 这相当于测试“实现等于它自身”——永远通过。expected 必须是手写的字面值。 |
| “再多模拟一点,隔离得更干净” | 模拟一大堆依赖 = 拆分代码或改做集成测试;你用假实现替换了最容易出错的部分。 |
| “不如也把这些胶水代码/CRUD 测试一下” | 其风险完全在被模拟掉的依赖中;使用集成测试。 |
| “测一个边界就够了” | 只有在边界处才能捕获差一错误。 |
| “测这几个用例更全面” | 同一等价类中的多个用例都是同质重复 = 形式主义。 |
| “快要发布了,验证调用就够了” | verify(called once) 什么也保护不了。时间紧 → 少写一些,而不是弄虚作假。 |
| “给整个对象做快照最简单” | 任何无关字段变动都会导致脆弱的测试失败,还会掩盖具体哪个契约出了问题。 |
| “这个测试很难写,绕着它模拟一下” | 难以测试 = 难以使用——这是设计信号;修复代码。 |
| “看起来专业 / 要求达到 80%” | 100 个照抄实现的测试什么也保护不了。看质量,不看数量。 |
危险信号——立即停止
- 只是为了点亮某一行/分支而添加测试
expected用生产逻辑计算,而不是手写- 一个“单元测试”模拟了 3 个或更多依赖
- 断言全是
verify(...)/toHaveBeenCalled,没有断言返回值/状态 - 测试名称像
test1/should work/case 2 - 测试 getter/setter/DTO 转换/纯胶水代码
- “大概把代码弄坏了,这个测试也不会变红”
命中任何一项 → 停止,回到这个问题:“这个测试要防止什么用户可见的不良结果?”答不上来 → 不要写。
演示示例(串联使用多种技巧)
被测对象:strip(prompt) 会移除证据摘录并保留简短指令;包含 ≥4 个 [n] 引用的段落会被丢弃。
行为清单 → 用例(不是跟着分支走):
// role+scenario+expectation; narrow/hard assertions; hand-written expected
test("subagent receiving a prompt with task_result has the dump stripped but instructions kept", () => {
const out = new HeuristicDumpStripper().strip(
"Summarize in three sentences.\n<task_result>…huge dump…</task_result>\nPrefer the latest conclusion.",
)
expect(out).not.toContain("<task_result>") // dump gone
expect(out).toContain("Summarize in three sentences") // leading instruction kept
expect(out).toContain("Prefer the latest conclusion") // trailing instruction kept
})
// Boundary values: threshold >=4, test "exactly 3 kept / exactly 4 dropped"
test("paragraph with exactly 3 citations is kept", () => {
expect(strip("conclusion [1][2][3] still body")).toContain("still body")
})
test("paragraph with exactly 4 citations is dropped", () => {
expect(strip("noise [1][2][3][4] should be removed")).not.toContain("should be removed")
})
// Dirty input + fallback: non-empty when everything is stripped
test("falls back to truncated original (non-empty) when stripping empties it", () => {
expect(strip("<task_result>only dump</task_result>").length).toBeGreaterThan(0)
})
**变异检查:**将 hits.length >= 4 改为 >= 5;“恰好 4 个时被丢弃”必须变红。如果全部通过 → 阈值未经过测试 → 重写。
检查清单(每个测试运行一次)
- 值得测试吗?复杂逻辑 → 单元测试;胶水代码 → 集成测试
- 用例来自行为清单,而不是从源码分支照搬
- 每个等价类选一个代表;每个阈值覆盖边界值±1;覆盖“多个条件同时命中”
- 空输入/超大输入/错误输入/恶意输入各测一个用例
- 只断言返回值/可观察状态/副作用;范围小且难以伪造;
expected手写 - 只模拟外部 IO/时间/RNG;单元逻辑保持真实;模拟数量 ≤ 2-3
- 测试名称 = 角色+场景+预期;通过公共 API 进入
- 变异检查通过:破坏生产代码后,这个测试会变红