用途
测试驱动开发(TDD)技能用于在实现任何功能或修复缺陷之前,先编写测试。其核心原则是:先写测试,看它失败,再写最小代码使其通过。如果没看到测试失败,就无法确定它测试的是正确的东西。违反规则的字面要求,就是违反规则的精神。
何时使用
始终适用于:新功能、缺陷修复、重构、行为变更。
例外情况(需询问人类伙伴):一次性原型、生成的代码、配置文件。
如果出现“就这一次跳过 TDD”的想法,应停止,这是一种合理化借口。
铁律
没有失败的测试,就不写生产代码。
如果在测试之前写了代码,应删除它并重新开始。没有例外:不要保留作为“参考”,不要在写测试时“改编”它,不要看它。删除就是删除。从测试重新实现。
红-绿-重构循环
- RED(红):编写一个最小的失败测试,展示应该发生什么。要求:一个行为、清晰的名称、真实代码(除非不可避免,否则不用 mock)。
- 验证 RED:必须执行,不可跳过。运行测试,确认测试失败(不是报错)、失败消息符合预期、失败原因是功能缺失(不是拼写错误)。如果测试通过,说明测试的是已有行为,需修正测试;如果测试报错,修复错误并重新运行直到正确失败。
- GREEN(绿):编写最简单的代码使测试通过。不要添加功能、重构其他代码或超出测试范围“改进”。
- 验证 GREEN:必须执行。运行测试,确认测试通过、其他测试仍然通过、输出干净(无错误、警告)。如果测试失败,修复代码而非测试;如果其他测试失败,立即修复。
- REFACTOR(重构):仅在绿灯后进行。消除重复、改进名称、提取辅助函数。保持测试绿灯,不添加行为。
- 重复:为下一个功能编写下一个失败测试。
好测试的标准
- 最小:只测一件事。名称中有“and”?拆分它。
- 清晰:名称描述行为,而非 test1。
- 展示意图:演示期望的 API,而非掩盖代码应做什么。
为什么顺序重要
- “我之后写测试来验证它能工作”:代码后写的测试会立即通过,立即通过证明不了什么——可能测错东西、可能测实现而非行为、可能遗漏忘记的边界情况、你从未看到它捕获缺陷。测试先行强制你看到测试失败,证明它确实在测试某些东西。
- “我已经手动测试了所有边界情况”:手动测试是临时的,没有记录、代码变更时无法重跑、压力下容易忘记情况。“我试的时候能工作”不等于全面。自动化测试是系统性的,每次运行方式相同。
- “删除 X 小时的工作是浪费”:沉没成本谬误。时间已经过去。现在的选择是:删除并用 TDD 重写(再花 X 小时,高置信度),或保留并之后加测试(30 分钟,低置信度,可能有缺陷)。真正的“浪费”是保留你无法信任的代码。没有真正测试的工作代码是技术债务。
- “TDD 是教条的,务实意味着变通”:TDD 就是务实的——在提交前发现缺陷(比之后调试更快)、防止回归(测试立即捕获破坏)、记录行为(测试展示如何使用代码)、支持重构(自由修改,测试捕获破坏)。“务实”的捷径等于在生产中调试,更慢。
- “之后写测试达到相同目标——是精神而非仪式”:不对。之后写测试回答“这做什么?”;测试先行回答“这应该做什么?”之后写测试受实现偏见影响,你测试你构建的东西,而非要求的东西。你验证记得的边界情况,而非发现的边界情况。测试先行强制在实现前发现边界情况。之后 30 分钟的测试不等于 TDD。你获得覆盖率,失去测试有效的证明。
常见合理化借口
- “太简单不用测”:简单代码会坏。测试只需 30 秒。
- “我之后测”:立即通过的测试证明不了什么。
- “之后测达到相同目标”:之后测 = “这做什么?”;先测 = “这应该做什么?”
- “已经手动测过”:临时不等于系统。没有记录,无法重跑。
- “删除 X 小时是浪费”:沉没成本谬误。保留未验证代码是技术债务。
- “保留作参考,先写测试”:你会改编它。那就是之后测。删除就是删除。
- “需要先探索”:可以。丢弃探索,从 TDD 开始。
- “测试难 = 设计不清”:听测试的。难测试 = 难使用。
- “TDD 会拖慢我”:TDD 比调试快。务实 = 测试先行。
- “手动测试更快”:手动不证明边界情况。每次变更都要重测。
- “现有代码没有测试”:你在改进它。为现有代码添加测试。
红旗信号——停止并重新开始
代码先于测试、实现后测试、测试立即通过、无法解释测试为何失败、测试“稍后”添加、合理化“就这一次”、“我已经手动测过”、“之后测达到相同目的”、“是精神而非仪式”、“保留作参考”或“改编现有代码”、“已经花了 X 小时,删除是浪费”、“TDD 是教条的,我在务实”、“这不一样因为……”——所有这些都意味着:删除代码,从 TDD 重新开始。
示例:缺陷修复
缺陷:空邮箱被接受。
RED:编写测试 test('rejects empty email', async () => { const result = await submitForm({ email: '' }); expect(result.error).toBe('Email required'); });
验证 RED:运行 npm test,得到 FAIL: expected 'Email required', got undefined。
GREEN:编写最小代码:function submitForm(data: FormData) { if (!data.email?.trim()) { return { error: 'Email required' }; } // ... }
验证 GREEN:运行 npm test,得到 PASS。
REFACTOR:如需要,为多个字段提取验证。
验证清单
在标记工作完成前:每个新函数/方法都有测试;在实现前看到每个测试失败;每个测试因预期原因失败(功能缺失,不是拼写错误);编写最小代码使每个测试通过;所有测试通过;输出干净(无错误、警告);测试使用真实代码(仅在不可避免时用 mock);覆盖边界情况和错误。无法勾选所有项?你跳过了 TDD,重新开始。
卡住时
- 不知道如何测试:写期望的 API。先写断言。询问人类伙伴。
- 测试太复杂:设计太复杂。简化接口。
- 必须 mock 一切:代码太耦合。使用依赖注入。
- 测试设置巨大:提取辅助函数。仍然复杂?简化设计。
调试集成
发现缺陷?编写失败测试复现它。遵循 TDD 循环。测试证明修复并防止回归。永远不要在没有测试的情况下修复缺陷。
测试反模式
添加 mock 或测试工具时,阅读 @testing-anti-patterns.md 以避免常见陷阱:测试 mock 行为而非真实行为、向生产类添加仅测试方法、不理解依赖就 mock。
最终规则
生产代码 → 测试存在且先失败。否则 → 不是 TDD。未经人类伙伴许可,没有例外。