本节目标:能在 Maven 工程里引入 JUnit 5,写出有名字、有断言的测试,正确使用
@BeforeEach、@Nested、@ParameterizedTest,并看懂mvn test的输出。
适用版本:JDK 25(对照 JDK 21);JUnit Jupiter 5.11.4,Maven Surefire 3.5.6。第 16 章是全书唯一引入 JUnit 依赖的地方。
上一节留下的问题
到第 15 章,library 的规则已经不少:
- 借期 14 天。
- 最多续借 1 次。
- 逾期不能续借。
- 罚金每天 0.50 元。
- ISBN 要规范化并校验校验位。
- 检索语法按字段解析。
这些规则现在靠 main 方法手工验证:改一行代码,重新编译,敲几条命令,看输出对不对。问题是手工验证不可重复:今天测了「逾期 2 天」,明天改了日期计算,未必记得再测「刚好到期」「提前归还」「续借后逾期」。而且人眼看输出,很容易把 ¥1.00 看成 ¥1.0。
测试框架解决的就是这件事:把「期望什么」写成代码,让机器每次执行、每次比对。 这一节用 JUnit 5 把 LoanPolicy 的规则固定下来。
引入 JUnit 5
Maven 工程只需要一个测试依赖:
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.11.4</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
</plugin>
</plugins>
</build>
junit-jupiter 是聚合依赖,包含 junit-jupiter-api、junit-jupiter-engine 和 junit-jupiter-params。只引 api 不会执行测试,只引 engine 又缺注解;聚合依赖最省事。scope=test 让它不会进入运行时产物。
目录结构:
library/
├── pom.xml
└── src/
├── main/java/com/plume/library/
│ ├── domain/Book.java
│ ├── domain/Loan.java
│ ├── service/LoanPolicy.java
│ └── service/LibraryService.java
└── test/java/com/plume/library/
└── service/LoanPolicyTest.java
测试类放在 src/test/java 下,包名和被测类一致。这样测试能访问包级可见的成员,又不需要把生产代码的可见性放宽。
第一个测试:断言与命名
package com.plume.library.service;
import com.plume.library.domain.Loan;
import com.plume.library.domain.LoanStatus;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import static org.junit.jupiter.api.Assertions.assertEquals;
@DisplayName("借阅规则")
class LoanPolicyTest {
private static final LocalDate BORROWED = LocalDate.of(2026, 10, 14);
private static final LocalDate DUE = LocalDate.of(2026, 10, 28);
private LoanPolicy policy;
private Loan loan;
@BeforeEach
void setUp() {
Clock clock = Clock.fixed(
Instant.parse("2026-10-20T01:00:00Z"),
ZoneId.of("Asia/Shanghai"));
policy = new LoanPolicy(clock);
loan = new Loan("L-1", "9780134685991", "M-1", BORROWED, DUE, 0, LoanStatus.ACTIVE);
}
@Test
@DisplayName("借期是 14 天")
void dueDateIsFourteenDaysAfterBorrow() {
assertEquals(DUE, policy.dueDate(BORROWED));
assertEquals(14, LoanPolicy.LOAN_DAYS);
}
}
几点约定:
- 测试类和方法可以是包级可见,不需要
public。JUnit 5 不要求public,这是和 JUnit 4 的一个区别。 - 方法返回
void,无参数。带参数的测试方法要么是参数化测试,要么会被报错。 - 方法名描述行为,不要写
test1、testDue。dueDateIsFourteenDaysAfterBorrow读起来就是一条规则。 @DisplayName用中文,在报告和 IDE 里更直观。assertEquals(expected, actual)的顺序:第一个是期望值,第二个是实际值。写反了失败信息会反过来,排查时容易误读。
setUp() 里用 Clock.fixed 把「今天」固定成 2026-10-20。这样测试不依赖运行当天,也不会在跨年、闰日、夏令时切换时飘。LoanPolicy 的构造器接收 Clock,正是为了让这一步成为可能。
断言:不只是 assertEquals
JUnit 5 的 Assertions 提供了一组静态方法。常用的有:
| 断言 | 用途 |
|---|---|
assertEquals(expected, actual) | 值相等 |
assertNotEquals(unexpected, actual) | 值不等 |
assertTrue(condition) / assertFalse(condition) | 布尔条件 |
assertNull(value) / assertNotNull(value) | 空值检查 |
assertThrows(ExceptionType.class, executable) | 期望抛异常,返回异常对象 |
assertDoesNotThrow(executable) | 期望不抛异常 |
assertAll(executables...) | 一组断言全部执行,汇总失败 |
assertIterableEquals(expected, actual) | 集合逐元素相等 |
assertSame(expected, actual) | 引用相同 |
assertThrows 返回捕获到的异常,可以继续检查消息:
@Test
void secondRenewalIsRejected() {
Loan renewed = policy.renew(loan, LocalDate.of(2026, 10, 27));
LibraryException error = assertThrows(LibraryException.class,
() -> policy.renew(renewed, LocalDate.of(2026, 10, 28)));
assertTrue(error.getMessage().contains("最多续借 1 次"));
}
只断言异常类型不够。LibraryException 可能因为很多原因抛出,消息能区分「次数用完」和「已逾期」。但也不要断言完整消息,除非消息是契约的一部分;contains 比 equals 更稳,改文案时不会大面积失败。
assertAll 适合一组相关的检查,避免第一个失败后后面的信息看不到:
@Test
void firstRenewalExtendsByAnotherFourteenDays() {
Loan renewed = policy.renew(loan, LocalDate.of(2026, 10, 27));
assertAll(
() -> assertEquals(1, renewed.renewals()),
() -> assertEquals(LocalDate.of(2026, 11, 11), renewed.dueOn()),
() -> assertEquals(LoanStatus.ACTIVE, renewed.status()));
}
普通的多个 assertEquals 在第一个失败时就停止;assertAll 会执行完所有断言,一次性告诉你哪几个不对。
生命周期:每个测试都从干净状态开始
JUnit 5 默认给每个测试方法创建一个新的测试类实例。所以 policy 和 loan 不会在测试之间共享,setUp() 每次都会重新执行。
| 注解 | 执行时机 | 典型用途 |
|---|---|---|
@BeforeAll | 所有测试之前一次 | 启动数据库、加载大文件 |
@BeforeEach | 每个测试之前 | 构造被测对象、准备测试数据 |
@AfterEach | 每个测试之后 | 清理临时文件、恢复状态 |
@AfterAll | 所有测试之后一次 | 关闭资源 |
@BeforeAll / @AfterAll 默认必须是 static 方法,因为它们在任何实例创建之前执行。如果想让它们成为实例方法,在类上加 @TestInstance(TestInstance.Lifecycle.PER_CLASS),但这样所有测试共享同一个实例,实例字段会跨测试保留,必须自己管理状态隔离。
默认的 per-method 生命周期更安全:每个测试拿到全新的对象,执行顺序不影响结果。测试之间不应该有顺序依赖。 如果两个测试必须按顺序跑,通常说明它们共享了可变状态,应该拆开或把状态显式传进去。
@BeforeEach 里不要做太重的操作。它每个测试都跑一次,启动数据库、下载文件会让测试从毫秒级变成秒级。重资源放 @BeforeAll,或者用测试替身替代。
嵌套与参数化
@Nested 把相关的测试分组,报告里会显示层级:
@Nested
@DisplayName("续借")
class Renewal {
@Test
void firstRenewalExtendsByAnotherFourteenDays() {
Loan renewed = policy.renew(loan, LocalDate.of(2026, 10, 27));
assertEquals(LocalDate.of(2026, 11, 11), renewed.dueOn());
}
@Test
void overdueLoanCannotBeRenewed() {
assertThrows(LibraryException.class,
() -> policy.renew(loan, LocalDate.of(2026, 10, 29)));
}
}
嵌套类不能是 static,它可以访问外部类的字段和 @BeforeEach。报告里会显示成「借阅规则 > 续借 > …」,比一长串平铺的方法名清楚。
参数化测试用一份代码跑多组数据:
@ParameterizedTest(name = "{0} 归还,逾期 {1} 天")
@CsvSource({
"2026-10-28, 0",
"2026-10-29, 1",
"2026-11-13, 16"
})
void lateDaysAreCountedFromDueDate(String returnedOn, long expectedDays) {
assertEquals(expectedDays, policy.lateDays(loan, LocalDate.parse(returnedOn)));
}
@ParameterizedTest(name = "{0} 可以续借")
@ValueSource(strings = {"2026-10-14", "2026-10-20", "2026-10-28"})
void canRenewUntilDueDate(String today) {
assertTrue(policy.canRenew(loan, LocalDate.parse(today)));
}
@ValueSource 适合单一类型的简单值,@CsvSource 适合一行多列,@MethodSource 适合复杂对象。name 属性可以定制报告里的显示名,{0}、{1} 对应参数。参数类型会自动转换:String 转 LocalDate 需要自己在方法里 parse,转 long、int 则由 JUnit 完成。
参数化测试的价值在于边界成组出现。逾期天数 0、1、16 放在一起,读者一眼能看出「到期当天不算逾期,第二天算 1 天」。如果只写一个 16,边界条件就漏了。
在 library 里落地
LoanPolicyTest 的完整结构:
LoanPolicyTest
├── 借期是 14 天
├── 今天来自注入的 Clock
├── 续借
│ ├── 第一次续借延长 14 天
│ ├── 第二次续借被拒绝
│ ├── 逾期不能续借
│ ├── 已归还不能续借
│ ├── 到期日当天可以续借(参数化)
│ └── 到期日后不能续借
└── 逾期罚金
├── 逾期天数(参数化)
├── 每天 0.50 元
├── 按时归还没有罚金
└── 提前归还没有罚金
在本机 JDK 25 + Maven 3.9.12 下执行:
mvn -Dtest=LoanPolicyTest test
真实输出(节选):
[INFO] Running 借阅规则
[INFO] Running 逾期罚金
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.057 s -- in 逾期罚金
[INFO] Running 续借
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.023 s -- in 续借
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.128 s -- in 借阅规则
[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
两个细节:
- 外层类显示
Tests run: 0。 因为测试方法都在@Nested内部类里,外层类本身没有直接测试。总数 16 是有效的。 mvn test不显示测试方法名。 要看每个方法的结果,看target/surefire-reports/下的 XML/TXT,或者用 IDE 的测试视图。CI 里通常解析这些报告,而不是解析控制台。
这套测试覆盖了借期的正常路径和边界:到期当天、到期后一天、续借一次、续借两次、已归还、提前归还。规则再改时,先跑这 16 个测试,比手工敲命令可靠得多。
常见错误与边界
用了 JUnit 4 的注解。 org.junit.Test 是 JUnit 4,JUnit 5 用 org.junit.jupiter.api.Test。混用时 Surefire 可能一个测试都不执行,还显示 BUILD SUCCESS。
测试方法写成 private 或 static。 JUnit 5 要求测试方法非私有、非静态、返回 void、无参数(参数化测试除外)。写错时通常报「No tests found」。
assertEquals 参数顺序反了。 assertEquals(actual, expected) 编译通过,失败信息却把期望和实际颠倒。记住第一个是期望值。
多个测试共享静态可变状态。 JUnit 默认每个方法新建实例,但 static 字段仍然共享。测试顺序一变,结果就变。测试数据尽量放实例字段,在 @BeforeEach 里初始化。
一个测试验证太多件事。 失败时不知道是哪条规则坏了。一个测试对应一条行为,用 assertAll 聚合同一行为的多个侧面。
@BeforeAll 写成非静态。 默认生命周期下会报错。要么加 static,要么用 @TestInstance(PER_CLASS),后者要自己管理状态隔离。
Surefire 版本太老。 Surefire 2.22 之前不支持 JUnit Platform,需要 2.22+ 或 3.x。本书用 3.5.6。
测试里 Thread.sleep。 让测试变慢且不稳定。时间相关逻辑用 Clock,异步逻辑用轮询加超时,不要睡固定秒数。
小结
- JUnit 5 用
junit-jupiter聚合依赖,scope=test,Surefire 3.x 执行。 - 测试类和方法可以包级可见,方法返回
void、无参数,名字描述行为。 assertEquals(expected, actual)顺序固定;异常用assertThrows并检查消息;多断言用assertAll。- 默认每个测试新建实例,
@BeforeEach准备干净状态,测试之间不要有顺序依赖。 @Nested分组,@ParameterizedTest用@ValueSource/@CsvSource覆盖边界。mvn test的汇总数字看总数,具体方法看 Surefire 报告或 IDE。- 测试固定「今天」的方式是注入
Clock.fixed,不是改系统时间。
有了测试,下一个问题是:LibraryService 依赖仓储,测试时不可能每次都连真实文件或数据库。怎么用 Stub、Fake、Mock 把这些依赖替换掉?下一节讲测试替身。
阅读导航:上一节:15.3 正则表达式与 Pattern/Matcher · 下一节:16.2 测试替身:Stub、Fake 与 Mock 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。