name: tdd description: 测试驱动开发工作流 - 先写测试,再写实现
测试驱动开发 (TDD)
严格遵守红-绿-重构循环。没有失败的测试之前,绝不编写生产代码。
TDD 循环
┌─────────────────────────────────────────┐
│ │
│ ┌───────┐ │
│ │ 红 │ 编写一个失败的测试 │
│ └───┬───┘ │
│ │ │
│ ▼ │
│ ┌───────┐ │
│ │ 绿 │ 编写最少代码使其通过 │
│ └───┬───┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ 重构 │ 在不破坏测试的前提下改进│
│ └────┬─────┘ │
│ │ │
│ └──────────────────────────────┘
规则 (不可协商)
-
没有失败的测试,就没有生产代码
- 先写测试
- 看着它失败
- 然后才写实现
-
编写能失败的最简测试
- 每个测试一个断言
- 测试行为,而非实现
- 从最简单的情况开始
-
编写能通过的最简代码
- 不要预判未来需求
- 如果硬编码能让测试通过,就硬编码
- 只有在被测试逼迫时才进行泛化
-
只在绿灯时重构
- 重构前所有测试必须通过
- 重构过程中保持测试通过
- 只进行小步修改
工作流程
阶段 1: 红 (编写失败测试)
// 从你期望的 API 样子开始
describe('计算器', () => {
it('应该能将两个数字相加', () => {
const calc = new Calculator();
expect(calc.add(2, 3)).toBe(5);
});
});
运行测试 → 看着它失败 → 确认它因正确的理由而失败
阶段 2: 绿 (使其通过)
// 编写能通过的最简单代码
class Calculator {
add(a: number, b: number): number {
return 5; // 是的,这里硬编码完全可以!
}
}
运行测试 → 看着它通过 → (短暂地)庆祝
阶段 3: 添加另一个测试 (迫使泛化)
it('应该能加不同的数字', () => {
const calc = new Calculator();
expect(calc.add(1, 1)).toBe(2); // 迫使真正的实现
});
阶段 4: 再次变绿
class Calculator {
add(a: number, b: number): number {
return a + b; // 现在我们进行泛化
}
}
阶段 5: 重构
在测试通过的情况下,改进代码:
- 消除重复
- 改进命名
- 提取方法
- 始终保持测试通过
测试命名约定
使用此模式:当 [条件] 时,应该 [预期行为]
it('当输入为空时,应该返回空数组')
it('当邮箱无效时,应该抛出验证错误')
it('当连接失败时,应该重试3次')
TDD 检查清单
在编写任何代码之前,确认:
- [ ] 我有一个失败的测试
- [ ] 测试因预期原因而失败
- [ ] 测试的是行为,而非实现
- [ ] 测试名称描述了它要验证的内容
在标记为“绿”之前:
- [ ] 测试通过
- [ ] 我编写了必要的最少代码
- [ ] 我没有添加“额外”功能
在重构之前:
- [ ] 所有测试都是绿的
- [ ] 我心中有一个具体的改进目标
- [ ] 更改是小步且渐进的
常见的 TDD 错误
❌ 先写代码后写测试
这不是 TDD。事后编写的测试往往测试的是实现,而非行为。
❌ 一次编写太多测试
编写一个测试,让它通过,然后写下一个。保持在循环中。
❌ 步子迈得太大
如果你的实现超过几行代码,说明你跳过了步骤。添加中间测试。
❌ 测试实现细节
// 不好 - 测试实现
expect(user._hashedPassword).toMatch(/^[a-f0-9]{64}$/);
// 好 - 测试行为
expect(user.verifyPassword('correct')).toBe(true);
❌ 在红灯时重构
永远不要在测试失败时重构。先变绿。
使用 TDD 开始一个新功能
- 列出功能所需的行为 (用户故事 → 测试用例)
- 按从最简单到最复杂的顺序排列
- 为最简单的行为编写第一个测试
- 循环执行红-绿-重构
- 为边界情况添加测试
- 为错误处理添加测试
示例会话
目标: 实现一个 slugify 函数
// 测试 1: 最简单的情况
it('应该将输入转为小写', () => {
expect(slugify('Hello')).toBe('hello');
});
// 实现:return input.toLowerCase();
// 测试 2: 处理空格
it('应该用连字符替换空格', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
// 实现:return input.toLowerCase().replace(/ /g, '-');
// 测试 3: 处理特殊字符
it('应该移除特殊字符', () => {
expect(slugify('Hello, World!')).toBe('hello-world');
});
// 实现:添加 .replace(/[^a-z0-9-]/g, '');
// 测试 4: 边界情况
it('应该处理空字符串', () => {
expect(slugify('')).toBe('');
});
// 已经通过!无需更改。
记住
“TDD 不是关于测试。它是关于设计的。”
测试驱动你走向更好、更模块化的代码。相信这个过程。