测试驱动开发
核心原则
只有当一个测试首先因正确原因失败,然后因实现而通过时,行为变更才值得信赖。
测试应通过公共接口验证行为,而非实现细节。代码可以完全改变,但测试不应改变。
好的测试是集成风格的:它们通过公共API执行真实的代码路径。它们描述的是系统做什么,而非怎么做。好的测试读起来像一份规格说明:“用户可以使用有效购物车结账”精确地告诉了你存在什么能力。这些测试能够经受住重构,因为它们不关心内部结构。
坏的测试与实现耦合。它们模拟内部协作对象、测试私有方法、断言调用次数,或者通过外部手段而非公共接口进行验证。警示信号:重构后行为未变,但测试却失败了。
参见 tests.md、mocking.md 和 testing-anti-patterns.md。
铁律
没有首先编写一个会失败的测试,就不允许有任何受控的生产行为变更。
含义:
- 为一种行为编写一个测试。
- 运行它并观察它失败。
- 确认失败证明了缺失的行为,而非拼写错误/设置错误。
- 编写能使其通过的最小的生产代码变更。
- 仅在测试通过后进行重构。
如果实现代码在测试之前已经存在:
- 未经用户许可,不要删除用户的工作。
- 将其视为探索原型/参考,而非经过验证的实现。
- 建议将其搁置一旁,先编写会失败的测试,然后从测试驱动实现。
- 如果必须保留现有代码,首先添加一个回归/表征测试,然后用一个新的会失败的测试驱动行为变更。
反模式:水平切片
不要先编写所有测试,再编写所有实现。那是水平切片。
错误:
红色:test1, test2, test3, test4
绿色:impl1, impl2, impl3, impl4
正确:
红色 -> 绿色 -> 重构:行为 1
红色 -> 绿色 -> 重构:行为 2
红色 -> 绿色 -> 重构:行为 3
原因:批量测试编码了想象中的设计,超出了反馈循环,而且常常测试的是形状而非行为。
工作流
1. 规划行为
在编写代码之前:
- 当公共接口变更不明显时,向用户确认。
- 列出要测试的行为,而非实现步骤。
- 优先处理关键路径和复杂逻辑;不要追求虚假的完整性。
- 识别使用 深层模块 的机会。
- 为 可测试性 设计接口。
- 选择第一个追踪子弹行为。
提问:“必须存在什么公共行为?哪个行为最重要?”
2. 红色:一个会失败的测试
为一个可观察的行为编写一个最小的测试。
要求:
- 仅使用公共接口。
- 名称描述行为。
- 在可行的情况下测试真实代码。
- 仅在系统边界或不可避免的接缝处进行模拟。
- 在实现之前会失败。
运行最有用的精简命令,例如:
npm test path/to/test.test.ts
验证失败:
- 测试失败,而非由设置/拼写错误导致的错误。
- 失败原因与缺失的行为匹配。
- 如果测试立即通过,说明测试有误或行为已存在。修复测试或选择下一个缺失的行为。
3. 绿色:最小实现
只编写足以使当前测试通过的代码。
不要:
- 添加推测性的功能。
- 重构不相关的代码。
- 添加未来的选项。
- 因为“顺带做一下很容易”而扩大范围。
运行:
npm test path/to/test.test.ts
然后运行相关的周边测试。输出应该干净:没有新的警告、不稳定问题或隐藏的错误。
4. 重构:在通过状态下清理
仅在测试通过后进行重构。如果没有有用的重构,简要说明然后继续。
寻找 重构候选点:
- 重复
- 糟糕的命名
- 浅层模块
- 混乱的边界
- 可以隐藏在小接口后面的复杂性
规则:
- 行为和期望不变。
- 每次重构步骤后测试保持通过状态。
- 测试重构可以重命名、去重、提取夹具/构建器,或改进失败消息。
- 如果断言更改了预期行为,停止:这是一个新的红色循环。
5. 垂直重复
根据上一个循环的反馈选择下一个行为。一次一个行为。
每周期检查清单
[ ] 测试描述可观察的行为,而非实现
[ ] 测试仅使用公共接口
[ ] 测试在实现之前失败
[ ] 失败原因是正确的
[ ] 代码对于此测试是最小的
[ ] 未添加推测性行为
[ ] 相关测试干净通过
[ ] 仅在通过状态下考虑重构
提交闸门
为了可审计的工作,在可行时分开提交各个阶段:
- 红色提交:仅包含测试或最小脚手架以及会失败的断言。提交信息记录失败的命令和原因。
- 绿色提交:最小实现。提交信息记录通过的目标测试并提及已考虑重构。
- 重构提交:无行为或期望变更。提交信息说明发生了什么变化以及什么保持不变。
不要在提交中包含 AI 归属信息。
CLI 契约测试
对于 CLI 工具,命令表面是产品契约。优先采用在最低可靠级别进行的外向内测试:
- CLI 契约测试:使用假提供者或夹具执行根/子命令;断言 stdout、stderr、退出行为、JSON 形状。
- 单元测试:解析、比较、分类、上下文选择、错误分类。
- 提供者集成测试:文件系统夹具和假的外部服务;无需网络/认证。
- 生活烟雾测试:仅选择加入;断言形状/不变量,而非波动性精确输出。
手动 QA 回归规则
手动 QA 是发现手段,而非验证支柱。当它发现行为缺口时:
- 编写最低级别的红色回归测试来复现它。
- 验证失败证据。
- 实现绿色。
- 重新运行自动化测试。
- 仅在测试通过后重新运行手动 QA。
如果输出/文本/CLI 形状发生了变化,添加或更新 CLI 契约测试。
合理化陷阱
| 说法 | 回应 |
|---|---|
| “太简单了,不用测试” | 简单的行为仍然会回归。添加一个小测试。 |
| “我之后再测试” | 之后通过的测试不能证明该测试本可以失败。 |
| “我手动检查过了” | 手动检查不是可重复的回归测试。 |
| “需要先探索一下” | 没问题。先探索原型,然后把它放一边,开始 TDD。 |
| “测试很难写” | 接口可能难以使用。简化接缝/设计。 |
| “模拟让它变得简单” | 可能是在测试模拟行为。阅读反模式。 |
| “一个大测试就覆盖了” | 除非端到端契约需要更多,否则优先每个测试一个行为。 |
| “重构需要会失败的测试” | 纯重构从通过状态开始并保持通过状态;行为变更需要红色阶段。 |
红旗警告:停止并纠正
- 在会失败的测试之前进行了生产行为变更。
- 测试立即通过但声称是红色阶段。
- 未检查失败原因。
- 测试名称描述实现。
- 测试断言私有调用、调用顺序或内部协作对象。
- 模拟设置主导了测试逻辑。
- 新增方法仅用于测试。
- 在绿色阶段修复测试因为实现与测试不一致。
- 在任何实现之前批量编写测试。
卡住时
| 问题 | 行动 |
|---|---|
| 不知道如何测试 | 编写期望的公共 API 和第一个断言。 |
| 测试设置庞大 | 提取夹具/辅助函数;如果仍然庞大,简化设计。 |
| 必须模拟一切 | 代码可能耦合度过高。引入依赖注入/接缝。 |
| 现有代码没有测试 | 为当前行为添加表征测试,然后为变更编写红色测试。 |
| Bug 不清楚 | 首先手动复现/查看日志,然后编码最小化的回归测试。 |
完成前验证
- 新/变更的行为有测试。
- 每个新行为测试都曾因正确原因被观察到失败。
- 所有相关测试通过。
- 测试验证公共行为。
- 模拟仅限于边界或有正当理由。
- 未添加仅用于测试的生产 API。
- 手动 QA 的发现已转化为回归测试。