--- name: test-driven-development description: 在实现任何功能或修复 bug 时使用,在写实现代码之前 --- # 测试驱动开发 (TDD) ## 概述 先写测试。看它失败。写最小代码通过测试。 **核心原则:** 如果你没有看到测试失败,你就不知道它是否测试了正确的东西。 **违反规则的字面意思就是违反规则的精神。** ## 何时使用 **总是:** - 新功能 - Bug 修复 - 重构 - 行为变更 **例外(需要用户确认):** - 一次性原型 - 生成的代码 - 配置文件 想着"就这一次跳过 TDD"?停下来。那是在找借口。 ## 铁律 ``` 没有失败的测试,就不能写生产代码 ``` 先写代码再写测试?删除它。重新开始。 **没有例外:** - 不要把它作为"参考"保留 - 不要在写测试时"调整"它 - 不要看它 - 删除就是删除 从测试重新实现。就这样。 ## RED-GREEN-REFACTOR ``` RED(写失败测试) ↓ 验证失败正确 GREEN(最小代码) ↓ 验证通过全绿 REFACTOR(清理) ↓ 保持绿色 → 下一个 → RED ``` ### RED - 写失败的测试 写一个最小的测试,展示应该发生什么。 **好的示例:** ```typescript test('重试失败操作 3 次', async () => { let attempts = 0; const operation = () => { attempts++; if (attempts < 3) throw new Error('fail'); return 'success'; }; const result = await retryOperation(operation); expect(result).toBe('success'); expect(attempts).toBe(3); }); ``` 清晰的名称,测试真实行为,只测一件事 **坏的示例:** ```typescript test('retry works', async () => { const mock = jest.fn() .mockRejectedValueOnce(new Error()) .mockResolvedValueOnce('success'); await retryOperation(mock); expect(mock).toHaveBeenCalledTimes(2); }); ``` 模糊的名称,测试 mock 而不是代码 **要求:** - 一个行为 - 清晰的名称 - 真实代码(除非不可避免,否则不用 mock) ### 验证 RED - 看它失败 **强制执行。永远不要跳过。** ```bash npm test path/to/test.test.ts ``` 确认: - 测试失败(不是报错) - 失败消息是预期的 - 因为功能缺失而失败(不是拼写错误) **测试通过了?** 你在测试已有行为。修复测试。 **测试报错了?** 修复错误,重新运行直到它正确失败。 ### GREEN - 最小代码 写最简单的代码通过测试。 **好的示例:** ```typescript async function retryOperation(fn: () => Promise): Promise { for (let i = 0; i < 3; i++) { try { return await fn(); } catch (e) { if (i === 2) throw e; } } throw new Error('unreachable'); } ``` 刚好够通过 **坏的示例:** ```typescript async function retryOperation( fn: () => Promise, options?: { maxRetries?: number; backoff?: 'linear' | 'exponential'; onRetry?: (attempt: number) => void; } ): Promise { // YAGNI - 过度设计 } ``` 不要添加功能、重构其他代码,或"改进"超出测试范围的内容。 ### 验证 GREEN - 看它通过 **强制执行。** ```bash npm test path/to/test.test.ts ``` 确认: - 测试通过 - 其他测试仍然通过 - 输出干净(没有错误、警告) **测试失败?** 修复代码,不是测试。 **其他测试失败?** 现在修复。 ### REFACTOR - 清理 只有在绿色之后: - 移除重复 - 改进名称 - 提取辅助函数 保持测试绿色。不要添加行为。 ### 重复 下一个失败测试,测试下一个功能。 ## 好的测试 | 质量 | 好 | 坏 | |------|----|----| | **最小** | 一件事。名称中有"和"?拆分它。 | `test('验证邮箱和域名和空格')` | | **清晰** | 名称描述行为 | `test('test1')` | | **展示意图** | 展示期望的 API | 隐藏代码应该做什么 | ## 为什么顺序很重要 **"我之后写测试来验证它能工作"** 事后写的测试立即通过。立即通过什么都证明不了: - 可能测试了错误的东西 - 可能测试的是实现,不是行为 - 可能遗漏了你忘记的边界情况 - 你从未看到它捕获 bug 先测试强迫你看到测试失败,证明它确实在测试什么。 ## 常见借口 | 借口 | 现实 | |------|------| | "太简单不需要测试" | 简单代码也会出错。测试只需 30 秒。 | | "我之后再测试" | 测试立即通过什么都证明不了。 | | "已经手动测试过了" | 临时 ≠ 系统化。没有记录,无法重新运行。 | | "删除 X 小时的工作太浪费" | 沉没成本谬误。保留不可信的代码才是浪费。 | | "TDD 太教条了,务实意味着灵活" | TDD 就是务实的:更快发现 bug,防止回归。 | | "事后测试能达到相同目的" | 不。事后测试回答"这做了什么?"先测试回答"这应该做什么?" | ## 危险信号 - 停下来重新开始 - 先写代码再写测试 - 实现后才写测试 - 测试立即通过 - 无法解释测试为什么失败 - 测试是"之后"添加的 - 合理化"就这一次" - "我已经手动测试过了" - "保留作为参考"或"调整现有代码" **所有这些意味着:删除代码。用 TDD 重新开始。** ## 验证清单 完成工作前: - [ ] 每个新函数/方法都有测试 - [ ] 在实现之前看到每个测试失败 - [ ] 每个测试失败的原因是预期的(功能缺失,不是拼写错误) - [ ] 为每个测试写了最小代码通过 - [ ] 所有测试通过 - [ ] 输出干净(没有错误、警告) - [ ] 测试使用真实代码(mock 只在不可避免时使用) - [ ] 边界情况和错误都有覆盖 无法勾选所有项?你跳过了 TDD。重新开始。 ## 卡住时 | 问题 | 解决方案 | |------|----------| | 不知道如何测试 | 先写期望的 API。先写断言。问用户。 | | 测试太复杂 | 设计太复杂。简化接口。 | | 必须 mock 所有东西 | 代码耦合太紧。使用依赖注入。 | | 测试设置太大 | 提取辅助函数。仍然复杂?简化设计。 | ## 最终规则 ``` 生产代码 → 测试存在并且先失败了 否则 → 不是 TDD ``` 没有用户许可不能有例外。