name: test-driven-development description: 测试驱动开发
测试驱动开发
先写测试。看它失败。写最少的代码通过。重构。
**核心原则:**如果你没有看到测试失败,你就不知道它是否测试了正确的事情。
铁律
没有失败的测试,就不要有改变行为的生产代码
写了测试之前的代码?完全删除它。从测试重新开始实现。
**重构是例外:**重构步骤改变结构,不改变行为。测试保持绿色。不需要新的失败测试。
红-绿-重构循环
RED ──► 验证失败 ──► GREEN ──► 验证通过 ──► REFACTOR ──► 验证通过 ──► 下一个 RED
│ │ │
▼ ▼ ▼
错误的失败? 仍然失败? 破坏了测试?
修复测试,重试 修复代码,重试 修复,重试
RED - 编写失败的测试
为一个行为编写一个最小的测试。
好例子:
test('retries failed operations 3 times', async () => {
let attempts = 0;
const operation = async () => {
attempts++;
if (attempts < 3) throw new Error('fail');
return 'success';
};
const result = await retryOperation(operation);
expect(result).toBe('success');
expect(attempts).toBe(3);
});
清晰的名称,测试真实行为,断言可观察结果
坏例子:
test('retry works', async () => {
const mock = jest.fn()
.mockRejectedValueOnce(new Error())
.mockRejectedValueOnce(new Error())
.mockResolvedValueOnce('success');
await retryOperation(mock);
expect(mock).toHaveBeenCalledTimes(3);
});
名称模糊,只断言调用次数而没有验证结果,测试模拟机制而不是行为
**要求:**一个行为。清晰的名称。真实代码(如果不可避免,只使用模拟)。
验证RED - 看它失败
强制的。绝不能跳过。
npm test path/to/test.test.ts
测试必须因为正确的原因变红。可以接受的RED状态:
- 断言失败(预期行为缺失)
- 编译/类型错误(函数尚不存在)
不可接受的:运行时设置错误,导入失败,环境问题。
测试立即通过?你在测试现有行为—修复测试。 测试因为错误的原因失败?修复错误,重新运行直到它正确失败。
GREEN - 最小代码
写最简单的代码通过测试。
好例子:
async function retryOperation<T>(fn: () => Promise<T>): Promise<T> {
for (let i = 0; i < 3; i++) {
try {
return await fn();
} catch (e) {
if (i === 2) throw e;
}
}
throw new Error('unreachable');
}
只够通过
坏例子:
async function retryOperation<T>(
fn: () => Promise<T>,
options?: { maxRetries?: number; backoff?: 'linear' | 'exponential'; }
): Promise<T> { /* YAGNI */ }
过度工程化超出测试要求
只写测试要求的。没有额外功能,没有“改进”。
验证GREEN - 看它通过
强制的。
npm test path/to/test.test.ts
确认:测试通过。所有其他测试仍然通过。输出纯净(没有错误,警告)。
测试失败?修复代码,不是测试。 其他测试失败?现在修复,然后再继续。
REFACTOR - 清理
只有在绿色之后:去除重复。改善名称。提取助手。
保持测试绿色。不添加新行为。
重复
下一个失败的测试为下一个行为。
好的测试
**最小:**每个测试一件事。名称中的“和”?分开它。❌ test('validates email and domain and whitespace')
**清晰:**名称描述行为。❌ test('test1')
**显示意图:**展示期望的API使用,而不是实现细节。
示例:Bug修复
Bug: 空邮箱被接受
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: 如果模式重复,提取验证助手。
红旗 - 停止并重新开始
这些中的任何一个意味着删除代码并用TDD重新开始:
- 测试前写的代码
- 测试立即通过(测试现有行为)
- 不能解释为什么测试失败
- 合理化“就这一次”或“这次不同”
- 保留代码“作为参考”同时写测试
- 声称“测试后达到同样的目的”
卡住时
| 问题 | 解决方案 |
|---|---|
| 不知道如何测试 | 写下你希望存在的API。先写断言。 |
| 测试太复杂 | 设计太复杂。简化接口。 |
| 必须模拟一切 | 代码太耦合。引入依赖注入。 |
| 测试设置巨大 | 提取助手。仍然复杂?简化设计。 |
遗留代码(没有现有测试)
铁律(“删除并重新开始”)适用于你写的没有测试的新代码。对于没有测试的继承代码,使用特性化测试:
- 编写测试以捕获当前行为(即使“错误”)
- 运行测试,观察实际输出
- 更新断言以匹配现实(这些是“金主”)
- 现在你有了一个安全网,可以重构
- 为新的行为变化应用TDD
特性化测试锁定现有行为,以便你可以安全地重构。它们是入口,不是永久状态。
易变规则
测试必须是确定性的。禁止在单元测试中使用这些:
- 真正的睡眠/延迟 → 使用假定时器(
vi.useFakeTimers(),jest.useFakeTimers()) - 墙上的钟时间 → 注入时钟,断言注入的时间
- Math.random() → 种子或注入RNG
- 网络调用 → 在边界处模拟或使用MSW
- 文件系统竞态条件 → 使用具有唯一名称的临时目录
易变测试?修复或删除。易变测试侵蚀对整个套件的信任。
集成调试
发现bug?先写一个失败的测试来重现它。然后遵循TDD循环。测试证明修复并防止回归。
计划:测试列表
在进入循环之前,花2分钟列出你期望写的下一个3-10个测试。这可以防止局部最优设计,其中早期测试将你逼入角落。
示例测试列表,用于重试功能:
- 在失败时重试N次
- 在成功时返回结果
- 在最大重试次数耗尽后抛出
- 在尝试之间调用onRetry回调
- 尊重退避延迟
按顺序完成列表。根据学习添加/删除测试。
测试反模式
当编写涉及模拟、依赖项或测试工具的测试时:见references/testing-anti-patterns.md了解常见陷阱,包括测试模拟行为和向生产类添加仅测试方法。
哲学和合理化
对于常见反对意见的详细反驳(“我将在之后测试”,“删除工作是浪费”,“TDD是教条的”):见references/tdd-philosophy.md
最终规则
如果生产代码存在 → 测试首先存在并且首先失败
否则 → 不是TDD