《Java 编程入门》16.1 JUnit 5:断言、生命周期与参数化

从手工跑 main 验证借阅规则的痛点出发,给出 Maven 引入 JUnit 5 的最小 pom、断言与异常断言、测试生命周期、嵌套与参数化测试;再用 library 的 LoanPolicyTest 和真实 mvn test 输出,说明一条测试应该验证什么。

本节目标:能在 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发