第9章 单元测试
一、导读
1.1 本章学习目标
第9章讲解了单元测试的重要性。Uncle Bob 强调,测试不是可选项,而是编写代码的必要组成部分。本章的核心学习目标包括:
- 掌握测试驱动开发(TDD):先写测试,再写代码。
- 理解 FIRST 原则:快速、独立、可重复、自验证、及时。
- 学会每个测试一个断言:测试应该专注。
- 掌握测试代码也要整洁:测试代码不是二等公民。
- 理解测试覆盖率:不是唯一指标。
- 学会测试的可维护性:避免脆弱的测试。
1.2 为什么本章如此重要
Uncle Bob 提出了一个激进的观点:没有测试的代码是不可维护的。测试不仅是验证正确性的工具,更是设计工具。通过先写测试,你可以更好地理解需求,设计出更易测试的代码。
测试的重要性还在于:它是重构的安全网。没有测试,你不敢重构代码;有了测试,你可以放心地改善代码结构。
1.3 核心问题引导
你编写单元测试吗?覆盖率是多少?
你的测试是先写还是后写?
你的测试是否遵循 FIRST 原则?
测试代码是否和主代码一样整洁?
测试覆盖率是目标吗?
二、核心概念详解
2.1 测试驱动开发
2.1.1 TDD 的节奏
TDD 的节奏是:红-绿-重构。
红:写一个失败的测试
绿:写最少的代码让测试通过
重构:改善代码结构,保持测试通过
// 步骤1:红 - 写一个失败的测试
@Test
public void withdrawShouldReduceBalance() {
Account account = new Account(100);
account.withdraw(30);
assertEquals(70, account.getBalance()); // 编译失败:withdraw 方法不存在
}
// 步骤2:绿 - 写最少的代码让测试通过
public class Account {
private int balance;
public Account(int initialBalance) {
this.balance = initialBalance;
}
public void withdraw(int amount) {
balance -= amount;
}
public int getBalance() {
return balance;
}
}
// 步骤3:重构 - 改善代码
public class Account {
private Money balance;
public Account(Money initialBalance) {
this.balance = initialBalance;
}
public void withdraw(Money amount) {
if (amount.greaterThan(balance)) {
throw new InsufficientFundsException();
}
balance = balance.subtract(amount);
}
public Money getBalance() {
return balance;
}
}2.2 FIRST 原则
2.2.1 Fast(快速)
测试应该快速运行。慢的测试让人不想运行。
// 差:慢的测试
@Test
public void processOrder() {
// 启动数据库
// 发送网络请求
// 等待外部系统
// 测试运行 30 秒
}
// 好:快速的测试
@Test
public void processOrder() {
// 使用 mock 替代真实依赖
OrderRepository repo = mock(OrderRepository.class);
PaymentGateway gateway = mock(PaymentGateway.class);
// 测试运行 10 毫秒
}2.2.2 Independent(独立)
测试之间不应该相互依赖。
// 差:测试相互依赖
@Test
public void test1() {
// 创建用户
userService.createUser("user1");
}
@Test
public void test2() {
// 依赖 test1 创建的用户
User user = userService.findUser("user1");
assertNotNull(user);
}
// 好:测试独立
@BeforeEach
public void setUp() {
// 每个测试前重置状态
userService = new UserService(new InMemoryRepository());
}
@Test
public void testCreateUser() {
userService.createUser("user1");
User user = userService.findUser("user1");
assertNotNull(user);
}2.2.3 Repeatable(可重复)
测试应该在任何环境下都能运行。
// 差:依赖外部环境
@Test
public void testWithDatabase() {
// 依赖本地数据库
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost/test");
// 如果数据库不可用,测试失败
}
// 好:自包含
@Test
public void testWithInMemoryDb() {
// 使用内存数据库
Connection conn = DriverManager.getConnection("jdbc:h2:mem:test");
// 任何环境都能运行
}2.2.4 Self-Validating(自验证)
测试应该自动判断通过或失败,不需要人工检查。
// 差:需要人工检查
@Test
public void testProcessOrder() {
OrderResult result = orderService.processOrder(order);
System.out.println("Result: " + result);
// 需要人工查看输出
}
// 好:自动验证
@Test
public void testProcessOrder() {
OrderResult result = orderService.processOrder(order);
assertTrue(result.isSuccess());
assertEquals(OrderStatus.CONFIRMED, result.getStatus());
}2.2.5 Timely(及时)
测试应该及时编写,最好在代码之前。
// TDD:先写测试
@Test
public void calculateTotal_withVipDiscount_shouldApply20Percent() {
Order order = createOrderWithItems(100);
order.setCustomer(createVipCustomer());
Money total = priceCalculator.calculateTotal(order);
assertEquals(80.0, total.getValue());
}
// 然后写实现
public Money calculateTotal(Order order) {
Money subtotal = calculateSubtotal(order);
if (order.getCustomer().isVip()) {
return subtotal.multiply(0.8);
}
return subtotal;
}2.3 每个测试一个断言
2.3.1 专注的测试
// 差:一个测试验证太多
@Test
public void testOrderService() {
Order order = createOrder();
OrderResult result = orderService.processOrder(order);
// 验证太多东西
assertTrue(result.isSuccess());
assertEquals(OrderStatus.CONFIRMED, order.getStatus());
assertNotNull(order.getConfirmationNumber());
verify(emailService).sendConfirmation(order);
verify(inventoryService).reserve(order.getItems());
}
// 好:每个测试专注一件事
@Test
public void processOrder_shouldReturnSuccess() {
Order order = createOrder();
OrderResult result = orderService.processOrder(order);
assertTrue(result.isSuccess());
}
@Test
public void processOrder_shouldConfirmOrder() {
Order order = createOrder();
orderService.processOrder(order);
assertEquals(OrderStatus.CONFIRMED, order.getStatus());
}
@Test
public void processOrder_shouldSendConfirmationEmail() {
Order order = createOrder();
orderService.processOrder(order);
verify(emailService).sendConfirmation(order);
}2.4 测试代码也要整洁
2.4.1 测试代码不是二等公民
// 差:混乱的测试代码
@Test
public void test1() {
Order o = new Order();
o.addItem(new Item("P1", 10, 2));
o.addItem(new Item("P2", 20, 1));
OrderService s = new OrderService(null, null);
OrderResult r = s.processOrder(o);
assertTrue(r.isSuccess());
}
// 好:整洁的测试代码
@Test
public void processOrder_withValidOrder_shouldSucceed() {
// Given
Order order = createOrderWithItems(
item("P1", 10, 2),
item("P2", 20, 1)
);
// When
OrderResult result = orderService.processOrder(order);
// Then
assertTrue(result.isSuccess());
}
// 测试辅助方法
private Order createOrderWithItems(OrderItem... items) {
return new Order(Arrays.asList(items));
}
private OrderItem item(String productId, double price, int quantity) {
return new OrderItem(productId, price, quantity);
}2.5 测试覆盖率
2.5.1 覆盖率不是唯一指标
// 100% 覆盖率但测试质量差
@Test
public void testProcessOrder() {
Order order = createOrder();
// 只是调用了方法,没有验证行为
orderService.processOrder(order);
// 覆盖率 100%,但没有验证任何事
}
// 低覆盖率但测试质量高
@Test
public void processOrder_withValidOrder_shouldConfirm() {
Order order = createValidOrder();
OrderResult result = orderService.processOrder(order);
assertTrue(result.isSuccess());
assertEquals(OrderStatus.CONFIRMED, result.getStatus());
}
@Test
public void processOrder_withEmptyOrder_shouldFail() {
Order emptyOrder = new Order(Collections.emptyList());
assertThrows(IllegalArgumentException.class,
() -> orderService.processOrder(emptyOrder));
}
// 覆盖率 60%,但测试了关键行为三、实战案例
3.1 案例一:TDD 实现一个功能
// 需求:实现一个密码强度检查器
// 步骤1:写失败的测试
@Test
public void checkStrength_withShortPassword_shouldBeWeak() {
PasswordChecker checker = new PasswordChecker();
assertEquals(Strength.WEAK, checker.check("abc"));
}
@Test
public void checkStrength_withOnlyLowerCase_shouldBeWeak() {
PasswordChecker checker = new PasswordChecker();
assertEquals(Strength.WEAK, checker.check("abcdefgh"));
}
@Test
public void checkStrength_withUpperCaseAndLowerCase_shouldBeMedium() {
PasswordChecker checker = new PasswordChecker();
assertEquals(Strength.MEDIUM, checker.check("Abcdefgh"));
}
@Test
public void checkStrength_withDigitAndLetter_shouldBeMedium() {
PasswordChecker checker = new PasswordChecker();
assertEquals(Strength.MEDIUM, checker.check("abcdefg1"));
}
@Test
public void checkStrength_withUpperLowerDigit_shouldBeStrong() {
PasswordChecker checker = new PasswordChecker();
assertEquals(Strength.STRONG, checker.check("Abcdefg1"));
}
// 步骤2:实现代码
public class PasswordChecker {
public Strength check(String password) {
if (password.length() < 8) {
return Strength.WEAK;
}
boolean hasUpper = !password.equals(password.toLowerCase());
boolean hasLower = !password.equals(password.toUpperCase());
boolean hasDigit = password.chars().anyMatch(Character::isDigit);
int score = 0;
if (hasUpper) score++;
if (hasLower) score++;
if (hasDigit) score++;
if (score >= 3) return Strength.STRONG;
if (score >= 2) return Strength.MEDIUM;
return Strength.WEAK;
}
}
public enum Strength {
WEAK, MEDIUM, STRONG
}四、本章小结
第9章讲解了单元测试的重要性,核心要点包括:
TDD:先写测试,再写代码,红-绿-重构。
FIRST 原则:快速、独立、可重复、自验证、及时。
每个测试一个断言:测试应该专注。
测试代码也要整洁:测试不是二等公民。
覆盖率不是唯一指标:测试质量比覆盖率更重要。
Uncle Bob 的核心观点是:测试是代码的一部分,不是附加品。没有测试的代码是不可维护的。
五、思考与练习
思考题
你编写单元测试吗?覆盖率是多少?
你的测试遵循 FIRST 原则吗?
测试代码是否和主代码一样整洁?
编程练习
用 TDD 实现一个"字符串反转"功能。
为一个现有类编写单元测试。
找到一段测试代码,改善其可读性。