第1章:测试驱动开发是什么(What Is TDD?)
一、导读
1.1 本章学习目标
第1章是《Growing Object-Oriented Software, Guided by Tests》的开篇,Freeman 和 Pryce 以他们丰富的实践经验,阐述了测试驱动开发(TDD)的真正含义。TDD 不仅仅是"先写测试再写代码",它是一种设计方法,一种思维模式。
本章的核心学习目标包括:
- 理解 TDD 的核心思想:红-绿-重构循环。
- 认识 TDD 作为设计工具的价值:测试如何驱动设计决策。
- 理解测试的反馈循环:测试如何提供快速、可靠的反馈。
- 掌握 TDD 的节奏:小步前进,频繁验证。
- 区分 TDD 和"测试后开发":理解先写测试的重要性。
1.2 为什么第一章如此重要
很多人对 TDD 有误解,认为它只是"先写测试"。但 Freeman 和 Pryce 在本章就指出,TDD 的真正价值在于设计——测试是设计的工具,而不仅仅是验证的工具。
这一章奠定了全书的基调:TDD 不是关于测试的,而是关于设计的。测试只是手段,好的设计才是目的。
1.3 核心问题引导
TDD 的核心循环是什么?每一步的目的是什么?
TDD 如何帮助设计?测试如何揭示设计问题?
为什么"先写测试"如此重要?
TDD 的节奏是什么?为什么小步前进很重要?
TDD 和代码质量有什么关系?
二、核心概念详解
2.1 TDD 的核心循环
TDD 的核心是一个简单的循环:红-绿-重构。
红灯(Red):写一个失败的测试
↓
绿灯(Green):写最少的代码让测试通过
↓
重构(Refactor):改善代码结构,保持测试通过
↓
回到红灯2.1.1 红灯:写一个失败的测试
红灯阶段的目标是明确你要实现什么。测试描述了期望的行为,在代码实现之前,测试应该失败。
// 红灯:测试失败
@Test
public void shouldReturnSumOfTwoNumbers() {
Calculator calc = new Calculator();
assertEquals(5, calc.add(2, 3));
}
// 编译失败:Calculator 类还不存在2.1.2 绿灯:写最少的代码让测试通过
绿灯阶段的目标是让测试通过,但不是写出完美的代码。关键是快速到达绿灯,获得反馈。
// 绿灯:测试通过
public class Calculator {
public int add(int a, int b) {
return 5; // 最少的代码,让测试通过
}
}2.1.3 重构:改善代码结构
重构阶段的目标是改善代码的结构和质量,同时保持测试通过。
// 重构:改善实现
public class Calculator {
public int add(int a, int b) {
return a + b; // 正确的实现
}
}2.2 TDD 作为设计工具
TDD 的真正价值不在于测试,而在于设计。测试是设计的工具:
测试揭示设计问题:如果测试难以编写,说明设计有问题。
测试驱动接口设计:从使用者的角度设计 API。
测试验证设计决策:测试通过意味着设计是正确的。
// 测试难以编写 → 设计有问题
@Test
public void shouldSendNotification() {
// 需要创建太多依赖对象
// 说明设计耦合太紧
}
// 重构设计
@Test
public void shouldSendNotification() {
NotificationService service = new NotificationService(mockEmailSender);
service.notify("user123", "Hello");
verify(mockEmailSender).send("user123", "Hello");
}
// 测试容易编写 → 设计更清晰2.3 测试的反馈循环
TDD 提供快速、可靠的反馈循环:
编写测试(1-2分钟)
↓
运行测试,确认失败(几秒钟)
↓
编写实现(1-5分钟)
↓
运行测试,确认通过(几秒钟)
↓
重构(1-5分钟)
↓
运行测试,确认仍然通过(几秒钟)
↓
循环这个反馈循环的关键是速度。每次循环应该在几分钟内完成。如果循环太慢,就失去了 TDD 的价值。
2.4 TDD 的节奏
TDD 的节奏是小步前进:
小步前进:每次只实现一小部分功能。
频繁验证:每实现一小部分就运行测试。
持续重构:不断改善代码结构。
大步骤(危险):
- 写10个测试
- 写大量代码
- 运行测试
- 大量失败
- 难以定位问题
小步骤(安全):
- 写1个测试
- 写最少的代码
- 运行测试
- 通过
- 重构
- 写下一个测试2.5 TDD vs 测试后开发
TDD 和"先写代码再补测试"有本质区别:
| 维度 | TDD | 测试后开发 |
|---|---|---|
| 测试目的 | 设计工具 | 验证工具 |
| 测试时机 | 代码之前 | 代码之后 |
| 测试覆盖率 | 高(测试驱动设计) | 低(难以测试的代码被跳过) |
| 设计质量 | 高(测试揭示设计问题) | 低(测试适应现有设计) |
| 反馈速度 | 快(几分钟) | 慢(几小时或几天) |
| 重构信心 | 高(测试保护) | 低(测试不充分) |
三、实战案例
3.1 用 TDD 实现字符串计算器
// 第1步:红灯
@Test
public void shouldReturnZeroForEmptyString() {
assertEquals(0, StringCalculator.add(""));
}
// 绿灯
public class StringCalculator {
public static int add(String numbers) {
return 0;
}
}
// 第2步:红灯
@Test
public void shouldReturnNumberForSingleNumber() {
assertEquals(1, StringCalculator.add("1"));
assertEquals(2, StringCalculator.add("2"));
}
// 绿灯
public class StringCalculator {
public static int add(String numbers) {
if (numbers.isEmpty()) return 0;
return Integer.parseInt(numbers);
}
}
// 第3步:红灯
@Test
public void shouldReturnSumForTwoNumbers() {
assertEquals(3, StringCalculator.add("1,2"));
}
// 绿灯
public class StringCalculator {
public static int add(String numbers) {
if (numbers.isEmpty()) return 0;
String[] parts = numbers.split(",");
int sum = 0;
for (String part : parts) {
sum += Integer.parseInt(part);
}
return sum;
}
}
// 重构
public class StringCalculator {
public static int add(String numbers) {
if (numbers.isEmpty()) return 0;
return Arrays.stream(numbers.split(","))
.mapToInt(Integer::parseInt)
.sum();
}
}3.2 TDD 揭示设计问题
// 初始设计:测试难以编写
@Test
public void shouldCreateOrder() {
// 需要创建数据库连接、用户服务、库存服务...
// 测试变得复杂且脆弱
}
// TDD 揭示问题:依赖太多,耦合太紧
// 重构设计:引入依赖注入
@Test
public void shouldCreateOrder() {
OrderService service = new OrderService(
mockUserRepo, mockInventoryService, mockPaymentGateway);
Order order = service.createOrder("user123", List.of("item1", "item2"));
assertEquals("user123", order.getUserId());
assertEquals(2, order.getItems().size());
verify(mockInventoryService).reserve("item1");
verify(mockInventoryService).reserve("item2");
}
// 测试容易编写 → 设计更清晰,依赖可替换四、本章小结
第1章阐述了 TDD 的核心思想:
红-绿-重构:TDD 的核心循环,小步前进,频繁验证。
TDD 是设计工具:测试驱动设计,而不仅仅是验证。
快速反馈:每次循环应该在几分钟内完成。
小步前进:每次只实现一小部分功能。
先写测试:从使用者的角度设计 API。
TDD 不是关于测试的,而是关于设计的。测试只是手段,好的设计才是目的。
五、思考与练习
思考题
TDD 的核心价值是什么?是测试还是设计?
为什么"先写测试"比"后补测试"更好?
TDD 的小步前进有什么优势?
如果测试难以编写,说明了什么?
TDD 适合所有场景吗?有什么限制?
编程练习
用 TDD 实现 FizzBuzz:从最简单的情况开始,逐步增加复杂度。
用 TDD 实现栈:先写测试描述栈的行为,再实现栈。
用 TDD 重构一段代码:先为现有代码补充测试,再重构。