JUnit 5 完整使用指南——@ParameterizedTest、@DynamicTest、扩展模型
JUnit 5 完整使用指南——@ParameterizedTest、@DynamicTest、扩展模型
适读人群:有 JUnit 4 基础想升级到 JUnit 5 的工程师,或 JUnit 5 用了但只会 @Test 的开发者 | 阅读时长:约15分钟 | 核心价值:系统掌握 JUnit 5 的核心特性,让测试代码更简洁、更强大
那次让我重新认识 JUnit 5 的 Code Review
2022年初,我去一家公司做技术顾问,第一件事是看他们的测试代码。
打开测试文件,看到了这样的代码:
@Test
public void testCalculatePrice_case1() {
assertEquals(100.0, priceService.calculate("NORMAL", 100.0, 0));
}
@Test
public void testCalculatePrice_case2() {
assertEquals(90.0, priceService.calculate("MEMBER", 100.0, 0));
}
@Test
public void testCalculatePrice_case3() {
assertEquals(85.0, priceService.calculate("VIP", 100.0, 0));
}
@Test
public void testCalculatePrice_case4() {
assertEquals(80.0, priceService.calculate("SVIP", 100.0, 0));
}
// ... 一直到 case1717 个几乎相同的测试方法,只有参数不同。我问负责人:你们知道 JUnit 5 有 @ParameterizedTest 吗?
对方楞了一下:"JUnit 5?我们用的是 5,但感觉和 4 差不多,就是把 @RunWith 换成 @ExtendWith 了……"
这就是现实。大多数团队"升级"了 JUnit 5,但实际上只用了 5% 的功能,剩下 95% 的新特性根本没碰过。
这篇文章,我把 JUnit 5 最值得掌握的特性系统过一遍,配完整代码,拿走就能用。
JUnit 5 的架构:先理解三层结构
JUnit 5 不是一个单体框架,它由三个子项目组成:
- JUnit Platform:测试运行基础设施,定义了 TestEngine API,让各种测试框架(JUnit 5、JUnit 4、TestNG)都能在同一个平台上运行。
- JUnit Jupiter:JUnit 5 的新编程模型和扩展模型,包含我们日常用的所有注解。
- JUnit Vintage:兼容层,让老的 JUnit 3/4 测试能在 JUnit 5 平台上运行。
在 Maven 中,你需要的依赖是:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>junit-jupiter 是一个聚合依赖,包含了 junit-jupiter-api、junit-jupiter-params 和 junit-jupiter-engine。
@ParameterizedTest:告别重复测试方法
这是使用频率最高、收益最大的特性。
基础用法:@ValueSource
@ParameterizedTest
@ValueSource(strings = {"", " ", " ", "\t", "\n"})
void testIsBlank_whenInputIsBlank_shouldReturnTrue(String input) {
assertTrue(StringUtils.isBlank(input),
"输入 [" + input + "] 应该被识别为空白字符串");
}
@ParameterizedTest
@ValueSource(ints = {-100, -1, 0})
void testIsPositive_whenInputIsNotPositive_shouldReturnFalse(int input) {
assertFalse(NumberUtils.isPositive(input));
}多参数组合:@CsvSource
@ParameterizedTest(name = "会员类型={0}, 金额={1}, 预期折后价={2}")
@CsvSource({
"NORMAL, 100.0, 100.0",
"MEMBER, 100.0, 90.0",
"VIP, 100.0, 85.0",
"SVIP, 100.0, 80.0",
"NORMAL, 0.0, 0.0",
"VIP, 200.0, 170.0"
})
void testCalculatePrice(String memberType, double amount, double expected) {
double actual = priceService.calculate(memberType, amount, 0);
assertEquals(expected, actual, 0.001,
String.format("会员类型 %s 的折扣计算有误", memberType));
}原来的 17 个方法,现在变成 1 个。测试报告里还会按 name 参数显示每条用例的描述。
从外部文件加载:@CsvFileSource
当测试数据很多或需要业务人员维护时,把数据放在 CSV 文件里:
@ParameterizedTest
@CsvFileSource(resources = "/test-data/price-cases.csv", numLinesToSkip = 1)
void testCalculatePrice_fromFile(String memberType, double amount, double expected) {
assertEquals(expected, priceService.calculate(memberType, amount, 0), 0.001);
}resources/test-data/price-cases.csv 内容:
memberType,amount,expected
NORMAL,100.0,100.0
MEMBER,100.0,90.0
VIP,100.0,85.0复杂对象:@MethodSource
当参数是复杂对象时,用 @MethodSource 提供一个静态工厂方法:
@ParameterizedTest
@MethodSource("provideOrderTestCases")
void testProcessOrder(Order order, OrderStatus expectedStatus, String description) {
OrderResult result = orderService.process(order);
assertEquals(expectedStatus, result.getStatus(), description);
}
private static Stream<Arguments> provideOrderTestCases() {
return Stream.of(
Arguments.of(
Order.builder().amount(BigDecimal.valueOf(100)).userId(1L).build(),
OrderStatus.SUCCESS,
"正常订单应该处理成功"
),
Arguments.of(
Order.builder().amount(BigDecimal.ZERO).userId(1L).build(),
OrderStatus.FAILED,
"零金额订单应该失败"
),
Arguments.of(
Order.builder().amount(BigDecimal.valueOf(100)).userId(null).build(),
OrderStatus.FAILED,
"无用户ID的订单应该失败"
)
);
}@DynamicTest:运行时生成测试用例
有时候,测试用例需要在运行时才能确定,比如从数据库读取测试数据,或根据配置文件动态生成。这时候用 @TestFactory + @DynamicTest:
@TestFactory
Collection<DynamicTest> testAllActiveCoupons() {
// 假设从某个地方获取需要测试的优惠券列表
List<Coupon> coupons = couponRepository.findAllActive();
return coupons.stream()
.map(coupon -> DynamicTest.dynamicTest(
"优惠券[" + coupon.getCode() + "]验证",
() -> {
CouponValidationResult result = couponService.validate(coupon.getCode());
assertTrue(result.isValid(),
"有效优惠券 " + coupon.getCode() + " 验证应该通过,实际结果:" + result.getReason());
assertNotNull(result.getDiscount());
assertTrue(result.getDiscount().compareTo(BigDecimal.ZERO) > 0,
"折扣金额应大于0");
}
))
.collect(Collectors.toList());
}还可以用流式 API 组织更复杂的动态测试:
@TestFactory
Stream<DynamicNode> testPricingRules() {
return Stream.of(
DynamicContainer.dynamicContainer("普通用户价格规则",
Stream.of(
DynamicTest.dynamicTest("无折扣",
() -> assertEquals(100.0, priceService.calculate("NORMAL", 100.0, 0))),
DynamicTest.dynamicTest("折扣上限验证",
() -> assertEquals(100.0, priceService.calculate("NORMAL", 100.0, 50)))
)
),
DynamicContainer.dynamicContainer("会员用户价格规则",
Stream.of(
DynamicTest.dynamicTest("标准会员折扣",
() -> assertEquals(90.0, priceService.calculate("MEMBER", 100.0, 0))),
DynamicTest.dynamicTest("叠加优惠券",
() -> assertEquals(80.0, priceService.calculate("MEMBER", 100.0, 10)))
)
)
);
}JUnit 5 扩展模型:Extension API
这是 JUnit 5 最强大的特性,也是最少被人用好的。
JUnit 4 里,扩展机制靠 @RunWith 和 @Rule,设计混乱。JUnit 5 统一为 Extension API,一个扩展接口有十几个回调点可以实现。
常用扩展接口
| 接口 | 触发时机 | 典型用途 |
|---|---|---|
BeforeAllCallback | 所有测试前 | 启动嵌入式服务器 |
BeforeEachCallback | 每个测试前 | 重置测试状态 |
AfterEachCallback | 每个测试后 | 清理资源 |
ParameterResolver | 解析测试方法参数 | 注入自定义对象 |
TestExecutionExceptionHandler | 测试抛出异常时 | 统一异常处理 |
TestWatcher | 测试完成时 | 记录测试结果 |
实战:写一个慢测试检测扩展
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SlowTestThreshold {
long milliseconds() default 1000;
}
public class SlowTestDetectorExtension implements BeforeEachCallback, AfterEachCallback {
private static final String START_TIME_KEY = "startTime";
@Override
public void beforeEach(ExtensionContext context) {
context.getStore(ExtensionContext.Namespace.GLOBAL)
.put(START_TIME_KEY, System.currentTimeMillis());
}
@Override
public void afterEach(ExtensionContext context) {
long startTime = (long) context.getStore(ExtensionContext.Namespace.GLOBAL)
.get(START_TIME_KEY);
long duration = System.currentTimeMillis() - startTime;
SlowTestThreshold annotation = context.getRequiredTestMethod()
.getAnnotation(SlowTestThreshold.class);
long threshold = annotation != null ? annotation.milliseconds() : 1000;
if (duration > threshold) {
System.out.printf("[SLOW TEST WARNING] %s.%s 执行耗时 %dms,超过阈值 %dms%n",
context.getRequiredTestClass().getSimpleName(),
context.getRequiredTestMethod().getName(),
duration,
threshold);
}
}
}
// 使用
@ExtendWith(SlowTestDetectorExtension.class)
class PerformanceSensitiveServiceTest {
@Test
@SlowTestThreshold(milliseconds = 500)
void testCriticalPath() {
// 如果这个测试超过 500ms,控制台会打警告
orderService.processLargeOrder();
}
}实战:写一个测试隔离扩展(清理线程本地变量)
public class ThreadLocalCleanupExtension implements AfterEachCallback {
@Override
public void afterEach(ExtensionContext context) {
// 清理所有我们自己管理的 ThreadLocal
UserContext.clear();
RequestContext.clear();
TransactionContext.clear();
}
}自动注册扩展:@AutoService
不想每个测试类都写 @ExtendWith(MyExtension.class),可以用 ServiceLoader 机制自动注册。在 src/test/resources/META-INF/services/ 下创建文件 org.junit.jupiter.api.extension.Extension,写入扩展类的全限定名。
踩坑实录三则
踩坑一:@ParameterizedTest 的 name 参数里用了特殊字符导致报告乱码
现象:@CsvSource 里的中文在测试报告里变成了乱码。
原因:Maven Surefire 插件默认编码不是 UTF-8。
解法:在 pom.xml 里加:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-Dfile.encoding=UTF-8</argLine>
</configuration>
</plugin>踩坑二:@TestFactory 返回了 Collection,但内部测试互相影响
现象:动态测试里,前一个测试修改了共享状态,后一个测试数据不对。
原因:@TestFactory 里的动态测试不会像 @Test 那样自动做测试隔离,@BeforeEach 对动态测试不起作用。
解法:每个动态测试里自己做数据准备,或者把共享状态改成无状态的。
踩坑三:扩展通过 @ExtendWith 注册多个时,执行顺序问题
现象:@ExtendWith({ExtensionA.class, ExtensionB.class}) 时,A 和 B 的 beforeEach 执行顺序和预期不符。
原因:JUnit 5 的多扩展执行顺序是注册顺序(before 时 A→B,after 时 B→A,类似栈)。
解法:理解这个顺序后,把有依赖关系的扩展按正确顺序排列。
核心注解速查表
// 基础注解
@Test // 普通测试
@ParameterizedTest // 参数化测试
@TestFactory // 动态测试工厂
@RepeatedTest(n) // 重复执行 n 次
@Disabled("原因") // 跳过(替代 @Ignore)
@DisplayName("描述性名称") // 自定义测试名称
// 生命周期
@BeforeAll / @AfterAll // 类级别,必须是 static
@BeforeEach / @AfterEach // 方法级别
// 条件执行
@EnabledOnOs(OS.LINUX) // 只在 Linux 上执行
@EnabledIfSystemProperty(named="env", matches="ci")
@EnabledIfEnvironmentVariable(named="CI", matches="true")
// 标签与过滤
@Tag("slow") // 打标签,可在 CI 中按标签过滤
@Tag("integration")
// 断言增强
assertAll( // 聚合断言,所有失败都会报告
() -> assertEquals(1, result.getCode()),
() -> assertNotNull(result.getData()),
() -> assertEquals("success", result.getMessage())
);最后说一句
JUnit 5 发布已经六七年了,但很多团队还在用 5% 的功能。@ParameterizedTest 一个特性就能让你的测试代码减少一半的重复,扩展模型能让你的测试基础设施真正工程化。
下一篇我们聊 JUnit 5 的生命周期深度实战——@BeforeAll/@BeforeEach 的陷阱、嵌套测试的正确用法、并行执行的坑。
