优质单元测试:命名、结构和覆盖率

1. 概述

优质的单元测试不仅仅是“能跑过”的测试。它必须易于阅读、易于维护,当失败时必须能立即告诉你问题在哪,而无需花半天时间调试。本文探讨三个核心支柱:如何命名测试使其自我解释、按照AAA(准备-执行-断言)结构编写测试,以及对代码覆盖率(coverage)的正确认识,避免陷入“100%覆盖率却无用”的陷阱。

读完本文后,你将拥有一份具体的检查清单,在创建pull request前自审测试。

2. 必需的依赖

本文示例使用JUnit 5和AssertJ。如果是Spring Boot项目,spring-boot-starter-test已经包含了两者。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

3. 测试命名:名字要能讲故事

测试名称是他人在测试失败时首先阅读的文档。一个好的名称回答三个问题:正在测试什么、在什么条件下、期望什么结果。

3.1. 模式 methodName_condition_expectedResult

class OrderServiceTest {

    @Test
    void calculateTotal_whenCartIsEmpty_returnsZero() {
        OrderService service = new OrderService();
        Order order = new Order(List.of());

        BigDecimal total = service.calculateTotal(order);

        assertThat(total).isEqualTo(BigDecimal.ZERO);
    }

    @Test
    void calculateTotal_whenCartHasDiscountedItems_appliesDiscount() {
        OrderService service = new OrderService();
        Order order = new Order(List.of(
                new Item("SKU-1", BigDecimal.valueOf(100), 0.1)
        ));

        BigDecimal total = service.calculateTotal(order);

        assertThat(total).isEqualTo(BigDecimal.valueOf(90.0));
    }
}

说明:

  • 名称从被测方法开始(calculateTotal),然后是when...说明条件,最后是期望结果。
  • 当CI报告中测试失败时,仅看名称就知道错误在哪个逻辑分支——无需打开代码。

注意: 避免使用test1()、testCalculateTotal()这样的名称。这些名称没有传达任何上下文,迫使读者必须进入测试体才能理解正在检查什么。

3.2. 当方法名不足以表达时使用@DisplayName

对于复杂的业务规则,方法名冗长难读。使用@DisplayName编写更自然的句子,同时保持方法名简洁便于代码导航。

@Test
@DisplayName("当客户是VIP且订单超过100万时,额外降低5%")
void calculateTotal_vipCustomerLargeOrder_extraDiscount() {
    // ...
}

使用Maven Surefire或IDE运行时,输出会显示@DisplayName中的句子而非方法名,在报告中更易阅读。

4. AAA结构:准备 – 执行 – 断言

每个测试应该有三个清晰的部分,按正确的顺序进行,不能混淆。这有助于读者快速扫描:哪行准备数据、哪行调用行为、哪行检查结果。

@Test
void withdraw_whenBalanceIsSufficient_deductsAmount() {
    // Arrange
    Account account = new Account("ACC-001", BigDecimal.valueOf(500));

    // Act
    account.withdraw(BigDecimal.valueOf(200));

    // Assert
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(300));
}

说明:

  • Arrange 创建初始余额为500的Account对象。
  • Act 调用待测试的单一操作(withdraw)。
  • Assert 检查结果,使用AssertJ让断言句子如同散文般易读。

4.1. 一个测试,一个行为

测试做的“工作”越多,失败时就越难调试。避免编写这样的测试:

@Test
void accountOperations() {
    Account account = new Account("ACC-001", BigDecimal.valueOf(500));
    account.deposit(BigDecimal.valueOf(100));
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(600));

    account.withdraw(BigDecimal.valueOf(200));
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));

    account.withdraw(BigDecimal.valueOf(1000)); // 错误:余额不足
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));
}

如果第一个断言失败,后续两个操作不会运行——你丢失了信息。应该拆分为三个独立的测试:deposit_...、withdraw_sufficientBalance_...、withdraw_insufficientBalance_throwsException。

4.2. 异常测试必须检查正确的类型和消息

@Test
void withdraw_whenBalanceIsInsufficient_throwsIllegalStateException() {
    Account account = new Account("ACC-001", BigDecimal.valueOf(100));

    assertThatThrownBy(() -> account.withdraw(BigDecimal.valueOf(500)))
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("Insufficient balance");
}

assertThatThrownBy行在一步中调用操作并捕获异常。不应使用旧式的@Test(expected = ...),因为它不允许检查异常后的消息或状态。

5. 使用@ParameterizedTest减少代码重复

当多个不同的输入验证同一逻辑时,使用参数化测试而不是复制粘贴多个方法。

@ParameterizedTest
@CsvSource({
    "0, 0",
    "100, 10",
    "1000, 100",
    "-50, 0"
})
void calculateShippingFee_variousOrderAmounts_returnsExpectedFee(
        BigDecimal orderAmount, BigDecimal expectedFee) {

    ShippingCalculator calculator = new ShippingCalculator();

    BigDecimal fee = calculator.calculateFee(orderAmount);

    assertThat(fee).isEqualTo(expectedFee);
}

说明:

  • @CsvSource提供输入/输出集合,每行是一个独立的测试用例。
  • 报告会分别显示每个用例,能立即看出哪个用例失败,无需猜测。
calculateShippingFee_variousOrderAmounts_returnsExpectedFee(BigDecimal, BigDecimal)
  ✓ [1] 0, 0
  ✓ [2] 100, 10
  ✗ [3] 1000, 100
  ✓ [4] -50, 0

6. 恰当地使用Mock,不要过度Mock

为外部依赖(数据库、HTTP客户端、其他服务)使用mock,不要mock待测对象本身。

@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private PaymentService paymentService;

    @Test
    void processPayment_whenGatewayApproves_marksOrderAsPaid() {
        Order order = new Order("ORD-01", BigDecimal.valueOf(200));
        when(paymentGateway.charge(order.getAmount())).thenReturn(ApprovalResult.approved());

        PaymentResult result = paymentService.processPayment(order);

        assertThat(result.isSuccess()).isTrue();
        verify(paymentGateway).charge(BigDecimal.valueOf(200));
    }
}

说明:

  • @Mock为PaymentGateway创建模拟对象——即向外部调用的东西(可能是真实API)。
  • @InjectMocks将该mock注入到被测的PaymentService中。
  • verify确认使用正确参数调用了charge,不仅检查返回结果。

注意: 如果一个测试必须mock超过3-4个依赖,这是类做的事情太多的信号。考虑先拆分类,再拆分测试。

7. 覆盖率(coverage):参考指标,不是目标

高覆盖率不等于优质测试。例如下面的测试达到了divide方法的100%行覆盖率,但未能发现bug:

public int divide(int a, int b) {
    return a / b;
}
@Test
void divide_returnsQuotient() {
    Calculator calculator = new Calculator();

    int result = calculator.divide(10, 2);

    assertThat(result).isEqualTo(5);
}

此测试运行了所有代码行(100%覆盖率),但未测试b = 0的情况——这会在生产环境中抛出ArithmeticException。覆盖率只能衡量哪些行被执行了,不能衡量是否正确验证了。

7.1. 用覆盖率找盲点,不是为了美化报告

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.12</version>
    <executions>
        <execution>
            <goals><goal>prepare-agent</goal></goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals><goal>report</goal></goals>
        </execution>
    </executions>
</plugin>

运行mvn test,JaCoCo会在target/site/jacoco/index.html生成HTML报告。使用此报告来回答“哪些分支还没有测试覆盖”的问题,然后问自己“该分支有重要逻辑吗”——不要盲目追求百分比。

Element           Missed Instructions  Cov.
Calculator        12 of 40             70%
  divide()        4 of 8               50%  ← 未测试b=0的情况

7.2. 边界测试用例(edge case)比百分比更重要

补充对上面遗漏的情况的测试:

@Test
void divide_whenDivisorIsZero_throwsArithmeticException() {
    Calculator calculator = new Calculator();

    assertThatThrownBy(() -> calculator.divide(10, 0))
            .isInstanceOf(ArithmeticException.class);
}

这一行测试的价值远超写5个重复测试普通情况来提高覆盖率百分比。

8. 测试自审检查清单

在创建pull request之前,自我检查:

  • 测试名称是否清楚说明了条件和期望,还是只是test1、testX?
  • 每个测试是否恰好有一个行为,一个失败的理由?
  • 是否有边界测试用例:null、空、0、负值、上限?
  • 是否有异常测试用例,还是只测试成功路径?
  • 覆盖率低的地方是什么——是遗漏的重要逻辑,还是只是无需测试的getter/setter?

9. 结论

优质的单元测试是:失败时,你立即知道问题所在而无需调试。按method_condition_expected模式命名,保持AAA结构清晰且每个测试只有一个行为,使用覆盖率作为找盲点的工具而非达成目标——这是三条简单但足以将测试套件质量提升一个档次的原则。最难的部分不是编写断言,而是花时间思考代码在生产环境中可能遇到的边界情况。

testingjunitjavabest-practicesspring-boot