优质单元测试:命名、结构和覆盖率
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结构清晰且每个测试只有一个行为,使用覆盖率作为找盲点的工具而非达成目标——这是三条简单但足以将测试套件质量提升一个档次的原则。最难的部分不是编写断言,而是花时间思考代码在生产环境中可能遇到的边界情况。