JUnit 5 单元测试:注解、断言与扩展

FreeGuideOnline 最新 2026-06-17

简介

JUnit 5 是 Java 平台上最流行的单元测试框架,为现代软件开发提供了强大且灵活的测试支持。它由三个子项目组成:JUnit Platform(在 JVM 上启动测试框架的基础)、JUnit Jupiter(提供全新的编程模型和扩展机制)和JUnit Vintage(兼容 JUnit 3/4 的测试引擎)。本教程聚焦 JUnit Jupiter,手把手带你掌握核心的注解、断言以及扩展能力,帮助你写出清晰、可维护的测试代码。

环境准备

在开始之前,请确保你的项目已经引入 JUnit 5 依赖。如果你使用 Maven,只需在 pom.xml 中添加:

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

Gradle 用户则加入:

testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1'

大多数现代 IDE(IntelliJ IDEA、Eclipse、VS Code)均已内置对 JUnit 5 的支持,无需额外配置。

基础注解

JUnit Jupiter 提供了一套丰富的注解来标记测试方法和生命周期回调。以下是常用注解详解。

@Test

标识一个方法是测试方法。与 JUnit 4 不同,JUnit 5 的 @Test 不再需要任何属性(例如不再需要声明 expected 异常,改用断言)。

import org.junit.jupiter.api.Test;

class CalculatorTest {
    @Test
    void shouldAddTwoNumbers() {
        // 测试逻辑
    }
}

生命周期方法

  • @BeforeAll / @AfterAll:在所有测试方法之前/之后执行一次,必须标注在静态方法上(除非测试类生命周期被修改为 PER_CLASS)。
  • @BeforeEach / @AfterEach:在每个测试方法执行之前/之后运行,适用于初始化与清理测试环境。
import org.junit.jupiter.api.*;

class LifecycleTest {
    @BeforeAll
    static void initAll() {
        System.out.println("启动一次:连接数据库");
    }

    @BeforeEach
    void init() {
        System.out.println("每个测试前:准备测试数据");
    }

    @Test
    void test1() { /* ... */ }

    @Test
    void test2() { /* ... */ }

    @AfterEach
    void tearDown() {
        System.out.println("每个测试后:清理数据");
    }

    @AfterAll
    static void tearDownAll() {
        System.out.println("结束一次:断开数据库");
    }
}

其他常用注解

  • @DisplayName:为测试类或测试方法指定可读性更强的显示名称,支持空格、特殊字符甚至 emoji。
  • @Disabled:忽略某个测试方法或类,理由可通过字符串说明。
  • @Tag:给测试打标签,方便分组执行,例如 @Tag("fast")
  • @Nested:创建嵌套测试类,用于表示测试上下文层级,能够共享外部类的初始化逻辑。
  • @RepeatedTest:重复执行测试指定次数,并可注入重复信息。
  • @ParameterizedTest:参数化测试,将在扩展部分介绍。
  • @TestFactory:动态测试,用于运行时生成测试用例。
@DisplayName("计算器测试")
class CalculatorTest {

    @Test
    @DisplayName("➕ 加法测试")
    @Tag("math")
    void testAddition() { /* ... */ }

    @Test
    @Disabled("乘法功能暂未实现")
    void testMultiplication() { /* ... */ }

    @Nested
    @DisplayName("除零操作")
    class DivisionByZero {
        @Test
        void shouldThrowException() { /* ... */ }
    }
}

断言核心

断言是验证测试结果是否符合预期的核心手段。JUnit Jupiter 内置了 org.junit.jupiter.api.Assertions 类,提供丰富的静态断言方法。

基本断言

  • assertEquals(expected, actual):比较是否相等(对象用 equals)。
  • assertTrue(condition) / assertFalse(condition):验证布尔条件。
  • assertNull(object) / assertNotNull(object):验证对象是否为空。
  • assertSame(expected, actual) / assertNotSame(expected, actual):比较引用是否严格相同。
@Test
void basicAssertions() {
    int result = 3 + 5;
    assertEquals(8, result, "加法结果应为 8");
    assertTrue(result > 0);
}

异常断言

通过 assertThrows 验证代码是否抛出指定类型的异常,并能对异常对象进行进一步断言。

@Test
void exceptionTesting() {
    Exception exception = assertThrows(ArithmeticException.class, () -> {
        int i = 1 / 0;
    });
    assertEquals("/ by zero", exception.getMessage());
}

超时断言

使用 assertTimeout 确保被测代码在给定时间内完成,防止长时间阻塞。

@Test
void timeoutNotExceeded() {
    assertTimeout(Duration.ofMillis(100), () -> {
        Thread.sleep(50);
    });
}

分组断言(assertAll)

将多个断言放入一组,确保所有断言都被执行,而不会因第一个失败而终止,适合一次验证多个相关属性。

@Test
void groupedAssertions() {
    Person person = new Person("Alice", "alice@example.com");
    assertAll("person properties",
        () -> assertEquals("Alice", person.getName()),
        () -> assertTrue(person.getEmail().contains("@"))
    );
}

第三方断言库

除了内置断言,JUnit 5 可以无缝集成 AssertJ、Hamcrest、Truth 等第三方断言库,推荐使用 AssertJ 以获得流式断言和更丰富的失败描述。

// 使用 AssertJ
assertThat(actualList).hasSize(3).contains("apple", "orange");

假设(Assumptions)

假设用于在特定条件不满足时跳过测试,而不是让测试失败。主要通过 org.junit.jupiter.api.Assumptions 提供。

@Test
void testOnlyOnCi() {
    Assumptions.assumeTrue("CI".equals(System.getenv("ENV")), "仅在 CI 环境执行");
    // 后续测试逻辑
}

如果假设失败,会抛出 TestAbortedException,测试被标记为“跳过”而非“失败”。

参数化测试

参数化测试允许使用不同的参数多次运行同一个测试方法,避免重复代码。需要添加额外依赖 junit-jupiter-params(已包含在 junit-jupiter 聚合工件中)。使用 @ParameterizedTest 替代 @Test,并配合参数来源注解。

常见参数来源

  • @ValueSource:提供简单类型的字面值数组。
  • @EnumSource:传入枚举常量。
  • @MethodSource:指向一个静态工厂方法,返回参数流。
  • @CsvSource / @CsvFileSource:以 CSV 格式提供多个参数。
  • @ArgumentsSource:自定义参数提供器。
@ParameterizedTest
@ValueSource(strings = {"racecar", "radar", "able was I ere I saw elba"})
void palindromeShouldReadSameBackwards(String candidate) {
    assertTrue(isPalindrome(candidate));
}

@ParameterizedTest
@CsvSource({
    "apple,  5",
    "banana, 6",
    "kiwi,   4"
})
void fruitLength(String fruit, int expectedLength) {
    assertEquals(expectedLength, fruit.length());
}

参数转换与聚合

JUnit 5 会自动将字符串参数转换为目标类型。对于复杂对象,可以实现 ArgumentConverter 或使用 @AggregateWith 自定义聚合。此外,可以使用 @CsvToMap 等简写形式。

扩展模型

JUnit Jupiter 的扩展模型(Extensions)是其最强大的特性之一,允许开发者以声明式或编程式的方式增强测试行为,实现了真正的“防火墙”式分离。扩展通过实现 Extension 接口及其子接口来介入测试生命周期。

内置扩展示例

JUnit 5 本身已经利用扩展模型实现了许多功能,例如:

  • @TempDir:为每个测试方法或类注入一个临时目录,测试结束后自动清理。
  • 参数解析:通过实现 ParameterResolver,可以在测试方法中注入 TestInfoTestReporter 等对象,或自定义依赖。
@Test
void testWithTempDir(@TempDir Path tempDir) throws IOException {
    Path file = tempDir.resolve("test.txt");
    Files.writeString(file, "Hello");
    assertTrue(Files.exists(file));
}

自定义扩展

创建一个扩展通常需要实现以下接口之一或组合:

  • BeforeAllCallbackAfterAllCallbackBeforeEachCallbackAfterEachCallback:对应生命周期回调。
  • BeforeTestExecutionCallbackAfterTestExecutionCallback:在测试方法执行前后介入(区别于 BeforeEach 等,此处捕获的是实际执行即时点)。
  • TestInstancePostProcessor:测试实例创建后进行处理(如依赖注入)。
  • ParameterResolver:解析测试方法参数。
  • TestExecutionExceptionHandler:处理测试执行中抛出的异常。
  • Interceptor:针对测试模板方法(如 @TestTemplate)的拦截。

自定义扩展类需要实现相应接口,然后通过 @ExtendWith 注册到测试类或方法上。也可以使用 Java SPI 机制进行全局注册。

示例:一个简易的执行计时器扩展

import org.junit.jupiter.api.extension.*;

public class TimingExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

    private static final String START_TIME = "start time";

    @Override
    public void beforeTestExecution(ExtensionContext context) {
        context.getStore(ExtensionContext.Namespace.GLOBAL).put(START_TIME, System.currentTimeMillis());
    }

    @Override
    public void afterTestExecution(ExtensionContext context) {
        long start = context.getStore(ExtensionContext.Namespace.GLOBAL).remove(START_TIME, long.class);
        long duration = System.currentTimeMillis() - start;
        System.out.println(context.getRequiredTestMethod().getName() + " 耗时:" + duration + "ms");
    }
}

在测试类上使用:

@ExtendWith(TimingExtension.class)
class MyTest { ... }

测试实例生命周期

JUnit 5 默认测试类对每个测试方法创建一个新实例(PER_METHOD)。若你希望在同一个实例上执行所有测试(比如为了共享状态),可以在类上添加 @TestInstance(TestInstance.Lifecycle.PER_CLASS),此时 @BeforeAll@AfterAll 方法可以是非静态的,接口也可以默认方法实现。

编写一份完整的测试代码

综合运用注解、断言、扩展的实际测试示例:

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;

@DisplayName("字符串工具类测试")
class StringUtilityTest {

    private StringUtility stringUtility;

    @BeforeEach
    void setUp() {
        stringUtility = new StringUtility();
    }

    @Nested
    @DisplayName("翻转功能")
    class Reverse {

        @Test
        @DisplayName("正常字符串应成功翻转")
        void shouldReverseNormalString() {
            assertEquals("dcba", stringUtility.reverse("abcd"));
        }

        @Test
        @DisplayName("空字符串返回空字符串")
        void shouldReturnEmptyForEmptyInput() {
            assertEquals("", stringUtility.reverse(""));
        }

        @Test
        @DisplayName("执行时间不应超过10毫秒")
        void shouldCompleteQuickly() {
            assertTimeout(Duration.ofMillis(10), () -> stringUtility.reverse("a".repeat(1000)));
        }
    }

    @ParameterizedTest
    @CsvSource({"madam, true", "hello, false", "racecar, true"})
    @DisplayName("回文检测")
    void testIsPalindrome(String input, boolean expected) {
        assertEquals(expected, stringUtility.isPalindrome(input));
    }
}

总结

本教程从基础注解入手,逐步深入断言体系、参数化测试和强大的扩展模型,覆盖了使用 JUnit 5 编写单元测试的必备知识。JUnit 5 的设计不仅回归测试的本质,更通过丰富的扩展点让测试代码更加清晰、可复用。建议实际编码时多参考官方文档,并尝试将定制化的测试行为封装为扩展,构建团队自己的测试基础设施。