JUnit 5 单元测试:注解、断言与扩展
简介
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,可以在测试方法中注入TestInfo、TestReporter等对象,或自定义依赖。
@Test
void testWithTempDir(@TempDir Path tempDir) throws IOException {
Path file = tempDir.resolve("test.txt");
Files.writeString(file, "Hello");
assertTrue(Files.exists(file));
}
自定义扩展
创建一个扩展通常需要实现以下接口之一或组合:
BeforeAllCallback、AfterAllCallback、BeforeEachCallback、AfterEachCallback:对应生命周期回调。BeforeTestExecutionCallback、AfterTestExecutionCallback:在测试方法执行前后介入(区别于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 的设计不仅回归测试的本质,更通过丰富的扩展点让测试代码更加清晰、可复用。建议实际编码时多参考官方文档,并尝试将定制化的测试行为封装为扩展,构建团队自己的测试基础设施。