JUnit5

JUnit 5

目标:读完后能独立给 Maven 项目接入 JUnit 5,写出可靠的单元测试、参数化测试与异常测试,并能定位“IDE 能跑、Maven 不跑”等常见问题。

先理解 JUnit 5 的组成

junit5-architecture

JUnit 5 不是一个单独 JAR,而是三层体系:

组件作用
JUnit Platform发现、选择并启动测试;IDE、Maven、Gradle 都从这里接入
JUnit JupiterJUnit 5 的注解、断言、扩展 API 与测试引擎
JUnit Vintage兼容运行 JUnit 3/4 测试;新项目通常不需要

测试类写的是 Jupiter API,Surefire/IDE 通过 Platform 找到 Jupiter Engine,Engine 才真正执行测试。

Maven 项目接入

使用 BOM 统一 JUnit 组件版本:

代码块XML · 29 行收起展开
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.junit</groupId>
            <artifactId>junit-bom</artifactId>
            <version>5.11.4</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.5.2</version>
        </plugin>
    </plugins>
</build>

junit-jupiter 聚合了 API、参数化测试和 Jupiter Engine。使用 Spring Boot 时通常由 spring-boot-starter-test 和父 POM/BOM 管版本,不要再重复塞入另一套 JUnit 版本。

常见测试目录:

  • src/test/java/: 测试代码
  • src/test/resources/: 测试资源
  • target/surefire-reports: Maven 单元测试报告

第一条测试

代码块JAVA · 20 行收起展开
import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class CalculatorTest {

    @Test
    @DisplayName("两个正数相加")
    void addsTwoPositiveNumbers() {
        // Arrange
        Calculator calculator = new Calculator();

        // Act
        int result = calculator.add(2, 3);

        // Assert
        assertEquals(5, result);
    }
}

JUnit 5 的测试类和测试方法可以是 package-private,不要求 public。推荐方法名表达行为,@DisplayName 补充人类可读说明。

生命周期

junit5-lifecycle
注解执行时机常见用途
@BeforeAll当前测试类所有测试之前一次启动昂贵共享资源
@BeforeEach每个测试之前创建干净对象、准备数据
@Test测试主体验证一个行为
@AfterEach每个测试之后释放本次测试资源
@AfterAll当前测试类所有测试之后一次关闭共享资源

默认 @TestInstance(PER_METHOD):每个测试方法使用新的测试类实例,隔离更好;此时 @BeforeAll@AfterAll 通常必须是 static

代码块JAVA · 7 行收起展开
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class RepositoryTest {
    @BeforeAll
    void connectOnce() {
        // PER_CLASS 下可以不是 static
    }
}

不要用生命周期共享可变业务状态,也不要让测试依赖执行顺序。每个测试应能单独运行。

核心注解

注解用途
@Test普通测试
@DisplayName自定义显示名称
@Disabled暂时禁用,必须说明原因
@Tag给测试分类,如 unitslow
@Nested用内部类按场景组织测试
@RepeatedTest重复执行同一测试
@ParameterizedTest用多组输入运行同一逻辑
@TestFactory动态生成测试
@Timeout限制最长执行时间
@TempDir注入自动清理的临时目录
@ExtendWith注册 JUnit 5 扩展

断言

代码块JAVA · 11 行收起展开
@Test
void validatesOrder() {
    Order order = service.createOrder("book", 2);

    assertAll(
        () -> assertNotNull(order.id()),
        () -> assertEquals("book", order.product()),
        () -> assertEquals(2, order.quantity()),
        () -> assertTrue(order.total().signum() > 0)
    );
}

常用断言:

  • assertEquals(expected, actual):相等;注意 expected 在前。
  • assertTrue / assertFalse / assertNull / assertNotNull
  • assertAll:同组断言全部执行后统一报告。
  • assertIterableEquals / assertArrayEquals:集合或数组。
  • assertDoesNotThrow:明确要求不抛异常。
  • assertTimeout:验证耗时上限。

浮点数比较应给误差:

代码块JAVA · 1 行收起展开
assertEquals(0.3, 0.1 + 0.2, 1e-9);

异常测试要拿到真正的异常对象:

代码块JAVA · 9 行收起展开
@Test
void rejectsNegativeQuantity() {
    IllegalArgumentException error = assertThrows(
        IllegalArgumentException.class,
        () -> service.createOrder("book", -1)
    );

    assertEquals("quantity must be positive", error.getMessage());
}

参数化测试

简单数据使用 @ValueSource@CsvSource:

代码块JAVA · 9 行收起展开
@ParameterizedTest(name = "{0} 是否为偶数:{1}")
@CsvSource({
    "2, true",
    "3, false",
    "0, true"
})
void detectsEvenNumber(int input, boolean expected) {
    assertEquals(expected, Numbers.isEven(input));
}

复杂对象使用 @MethodSource:

代码块JAVA · 16 行收起展开
@ParameterizedTest
@MethodSource("invalidUsers")
void rejectsInvalidUser(User user, String message) {
    ValidationException error = assertThrows(
        ValidationException.class,
        () -> validator.validate(user)
    );
    assertEquals(message, error.getMessage());
}

static Stream<Arguments> invalidUsers() {
    return Stream.of(
        Arguments.of(new User("", 20), "name is blank"),
        Arguments.of(new User("Ada", -1), "age is invalid")
    );
}

其他来源包括 @NullSource@EmptySource@NullAndEmptySource@EnumSource。参数化测试适合“规则相同、输入不同”的场景,不要把完全不同的行为硬塞进一个方法。

嵌套、重复与动态测试

代码块JAVA · 8 行收起展开
@Nested
@DisplayName("余额不足时")
class WhenBalanceIsInsufficient {
    @Test
    void rejectsPayment() {
        // 场景测试
    }
}

@Nested 让 Given/When 场景更清晰。@RepeatedTest(10) 适合验证重复执行行为,但不能证明随机算法正确;随机测试应固定 seed,失败时才能复现。

@TestFactory 返回 DynamicTest 集合或流,适合运行时才知道测试集合的场景。普通固定输入优先使用参数化测试,因为报告和失败定位更直观。

Tag、临时目录与超时

@Tag("slow")
@Test
@Timeout(2)
void finishesWithinTwoSeconds(@TempDir Path tempDir) {
    // tempDir 在测试后自动清理
}
mvn -Dgroups=unit test
mvn -DexcludedGroups=slow test

assertTimeoutPreemptively 会在另一个线程执行代码,可能丢失 ThreadLocal、事务或安全上下文;涉及 Spring 事务时优先使用普通 assertTimeout 或框架级超时。

扩展模型与 Mockito

JUnit 5 用 Extension 统一替代 JUnit 4 的 Runner/Rule。Mockito 通过扩展接入:

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.14.2</version>
    <scope>test</scope>
</dependency>
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    OrderRepository repository;

    @InjectMocks
    OrderService service;

    @Test
    void savesCreatedOrder() {
        when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));

        Order order = service.create("book", 1);

        assertEquals("book", order.product());
        verify(repository).save(order);
        verifyNoMoreInteractions(repository);
    }
}

Mock 适合隔离慢速或不可控边界,如数据库、网络、消息系统。不要 Mock 值对象、集合和被测类内部的每一个细节;测试应验证可观察行为,而不是锁死实现步骤。

测试分层

junit5-test-pyramid
  • 单元测试:数量最多、毫秒级、无外部 I/O。
  • 组件/切片测试:验证一组协作对象或 Web/JPA 切片。
  • 集成测试:连接真实数据库、容器或中间件,数量更少。
  • 端到端测试:验证完整系统路径,最慢且维护成本最高。

JUnit 5 能承载各层测试,但是否加载 Spring Context、数据库或容器由项目测试策略决定。不要为了测一个纯函数启动整个应用。

Maven 执行方式

代码块BASH · 4 行收起展开
mvn test
mvn -Dtest=CalculatorTest test
mvn -Dtest=CalculatorTest#addsTwoPositiveNumbers test
mvn clean verify

Surefire 通常执行单元测试;Failsafe 通常运行 *IT 集成测试并在 verify 阶段确认结果。只执行 package 可能漏掉 Failsafe 的最终校验。

常见问题

现象优先检查
Maven 显示 0 tests测试类命名、Surefire 版本、Jupiter Engine 是否存在
IDE 能跑,Maven 不能mvn -v 的 JDK、有效 POM、IDE 自带运行器差异
@BeforeAll 报错默认生命周期下方法是否为 static
测试偶发失败时间、随机数、共享状态、执行顺序、并发与外部服务
断言信息难读是否一个测试塞了多个行为;是否该用 assertAll/参数化测试
Spring 测试很慢是否把本可纯单元测试的逻辑加载了整个 Context

好测试检查清单

  • 一个测试只验证一个清晰行为。
  • Arrange / Act / Assert 边界清楚。
  • 测试可独立运行,不依赖顺序和其他测试残留。
  • 不访问真实时间、随机数和网络,或已通过注入控制。
  • 正常路径、边界值、异常路径都有覆盖。
  • 失败信息能直接说明业务行为哪里不符合预期。
  • 没有长期无人处理的 @Disabled
  • 本地 mvn clean verify 与 CI 结果一致。

延伸阅读