9代码整洁之道

单元测试

测试驱动

TDD测试原则测试覆盖

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第9章 单元测试

一、导读

1.1 本章学习目标

第9章讲解了单元测试的重要性。Uncle Bob 强调,测试不是可选项,而是编写代码的必要组成部分。本章的核心学习目标包括:

  • 掌握测试驱动开发(TDD):先写测试,再写代码。
  • 理解 FIRST 原则:快速、独立、可重复、自验证、及时。
  • 学会每个测试一个断言:测试应该专注。
  • 掌握测试代码也要整洁:测试代码不是二等公民。
  • 理解测试覆盖率:不是唯一指标。
  • 学会测试的可维护性:避免脆弱的测试。

1.2 为什么本章如此重要

Uncle Bob 提出了一个激进的观点:没有测试的代码是不可维护的。测试不仅是验证正确性的工具,更是设计工具。通过先写测试,你可以更好地理解需求,设计出更易测试的代码。

测试的重要性还在于:它是重构的安全网。没有测试,你不敢重构代码;有了测试,你可以放心地改善代码结构。

1.3 核心问题引导

你编写单元测试吗?覆盖率是多少?

你的测试是先写还是后写?

你的测试是否遵循 FIRST 原则?

测试代码是否和主代码一样整洁?

测试覆盖率是目标吗?


二、核心概念详解

2.1 测试驱动开发

2.1.1 TDD 的节奏

TDD 的节奏是:红-绿-重构。

红:写一个失败的测试

绿:写最少的代码让测试通过

重构:改善代码结构,保持测试通过

java
// 步骤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(快速)

测试应该快速运行。慢的测试让人不想运行。

java
// 差:慢的测试
@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(独立)

测试之间不应该相互依赖。

java
// 差:测试相互依赖
@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(可重复)

测试应该在任何环境下都能运行。

java
// 差:依赖外部环境
@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(自验证)

测试应该自动判断通过或失败,不需要人工检查。

java
// 差:需要人工检查
@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(及时)

测试应该及时编写,最好在代码之前。

java
// 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 专注的测试

java
// 差:一个测试验证太多
@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 测试代码不是二等公民

java
// 差:混乱的测试代码
@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 覆盖率不是唯一指标

java
// 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 实现一个功能

java
// 需求:实现一个密码强度检查器

// 步骤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 实现一个"字符串反转"功能。

为一个现有类编写单元测试。

找到一段测试代码,改善其可读性。

9/10单元测试
0%