☰
JUnit5迁移实战:架构差异、渐进步骤与性能实测
2026/10/20 18:08:41 网站建设 项目流程

先交代一个背景:我在负责某个模拟订单系统的测试改造时,把一千三百多个原本用 JUnit4 写的测试用例整体切到了 JUnit5。单线程跑下来两者的耗时几乎一样,但真正拉开差距的,是并行能力、参数化测试、动态测试和测试报告的可读性。这篇文章不铺垫概念,直接分享三样东西:两个版本在设计和写法上的核心差异、一套不破坏现有测试的渐进迁移步骤,以及一组我在同一台机器上实测出来的性能数据。

1. 为什么说JUnit5是“推倒重来”而不是一次普普通通的版本号升级

很多同学以为 JUnit5 只是把注解换了个包名,加几个新注解而已。如果你也这么想,迁移时会非常痛苦。因为 JUnit5 的底层架构和 JUnit4 完全不是一回事,很多老写法在新版本里不是“改个 import”就能跑通的。

1.1 JUnit4在长期维护中积累的三个结构性麻烦

JUnit4 本身设计得并不差,但它诞生在一个相对单纯的时代,随着 Spring、Mockito、数据库测试框架的出现,几个结构性问题被放大了。

第一个问题是“一个测试类只能绑定一个 Runner”。JUnit4 通过@RunWith来指定测试的运行器,比如 Spring 测试用 SpringRunner,Mockito 测试用 MockitoJUnitRunner。如果我想在一个测试类里既用 Spring 容器又做参数化,就会非常别扭,因为你只能写一个 @RunWith,想组合就得自己写一个复合 Runner。很多团队最后都做过这种“把两个 Runner 功能揉在一起”的奇怪子类,维护成本极高。

第二个问题是 Rule 机制的作用域混乱。@Rule和@ClassRule的语义经常让人分不清:一个管每个测试方法,一个管整个测试类;而且自定义 Rule 时,你必须继承特定的 Rule 父类或实现 TestRule 接口,扩展方式非常封闭。说白了,它不是一个开放的扩展模型,而是一条单行道。

第三个问题更隐蔽:JUnit4 的测试发现机制依赖“命名约定 + 反射”。测试方法必须以 test 开头或标注 @Test,类必须定义为 public,方法必须返回 void 且无参数。这种做法在当年没问题,但构建工具、IDE 和各种第三方库都被这套约定绑死了,想扩展新的测试风格几乎没有空间。

1.2 JUnit5的三层架构:Platform、Jupiter、Vintage

JUnit5 之所以敢叫“5”,是因为它从根上把“测试平台”和“测试框架”拆开了。整个体系分成三层:

  • JUnit Platform:负责在 JVM 上启动测试框架,接受 TestEngine 的注册。
  • JUnit Jupiter:新的编程模型,就是我们日常写的 @Test、@BeforeEach、@ParameterizedTest 这些注解。
  • JUnit Vintage:一个兼容引擎,用来跑 JUnit3 和 JUnit4 写的老测试。

理解这三层关系很重要。JUnit4 不是被“兼容模式”硬塞进 JUnit5 的,而是变成了一个运行在 Platform 上的独立引擎。以后你甚至可以在同一个测试任务里混跑 JUnit4、JUnit5、TestNG,只要它们各自提供 TestEngine 适配器。

打个比方,以前每个存储设备都有自己的专用插座,现在平台统一成了一个 USB-C 口,Vintage 就是一个转接头。这个架构带来的最大好处是:迁移不必一步到位。你可以让老测试继续通过 Vintage 引擎跑,新测试用 Jupiter 写,两者在同一趟构建里互不干扰。这几乎是所有大型项目迁移到 JUnit5 的基石策略。

2. 功能对比:注解、断言、参数化、扩展机制逐个过

这一节主要解决一个实际问题:当你拿到一段 JUnit4 老代码,它应该怎么翻译成 JUnit5 的写法,以及新写法为什么更好。

2.1 注解体系变化:一张对照表看懂

老测试迁移到新版本,首先要面对的就是注解的改名。JUnit4 的org.junit.Test变成了 JUnit5 的org.junit.jupiter.api.Test,不少注解的语义也做了调整。

JUnit4JUnit5说明
@BeforeClass@BeforeAll所有测试运行前执行一次,JUnit4 要求 static 方法
@AfterClass@AfterAll所有测试运行后执行一次,JUnit4 要求 static 方法
@Before@BeforeEach每个测试方法运行前执行
@After@AfterEach每个测试方法运行后执行
@Ignore@Disabled禁用测试,JUnit5 也支持写原因
@RunWith@ExtendWith扩展机制,从 Runner 换成 Extension
@Rule / @ClassRule@ExtendWith + Extension规则统一收编为扩展点
@Category@Tag按分组筛选测试,JUnit5 的 Tag 更轻量

这里有个容易踩的细节:JUnit5 的 @BeforeAll 和 @AfterAll 默认要求方法是 static 的,但如果测试类标注了@TestInstance(PER_CLASS),就可以改成实例方法。这样你就可以在 @BeforeAll 里访问实例字段,不必再靠 static 变量传值。老代码里一堆private static PreparedStatement ps这种写法,迁移后很多都可以顺势改成实例字段。

另外,JUnit5 的测试类和方法不再强制要求 public。你可以写成包私有的类、包私有的方法,代码会干净很多。但这也带来了一个反面问题:IDE 的自动导入如果不仔细,容易把org.junit.jupiter.api.Test和org.junit.Test混在一起,编译期不报错,实际运行却走了 Vintage 引擎,后面我会专门讲这一坑。

2.2 断言升级:从“报第一个错”到assertAll

JUnit4 的 Assert 家族有一个致命的体验问题:一个测试方法里如果写了两个断言,第一个失败了,第二个根本不会执行。很多老测试不得不把一个方法拆成好几个小方法,每个方法只放一个断言,就是为了拿到完整的失败信息。

JUnit5 的 Assertions 补上了这个缺口,最值得推荐的是assertAll:

@Test void should_validate_order_amount() { // 三个断言都会执行,失败信息会全部聚合在结果里 assertAll( () -> assertEquals(10.0, order.getAmount()), () -> assertEquals("CNY", order.getCurrency()), () -> assertTrue(order.getStatus() == OrderStatus.CREATED) ); }

assertAll的优势在于:你可以在一个测试方法里表达一组业务约束,失败时看到所有不满足项,而不是像 JUnit4 那样只能一个个试错。对测试维护者来说,这一条就足够成为升级理由。

assertThrows也是 JUnit5 的一大亮点。JUnit4 里要捕获异常,通常是try/catch再把异常对象拿出来断言,或者用 ExpectedException Rule,绕来绕去。JUnit5 直接返回异常实例,你可以紧接着断言它的 message:

IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> orderService.create(-1)); assertEquals("金额不能为负数", ex.getMessage());

assertTimeout和assertTimeoutPreemptively需要格外注意。JUnit5 的assertTimeout不会中断正在执行的代码,只是等代码跑完后告诉你是否超时;assertTimeoutPreemptively则会新开线程去跑任务,超时就直接终止。后者虽然“看起来更爽”,但会在任务里产生线程泄漏和 ThreadLocal 污染,涉及数据库事务、Servlet 上下文这类场景时,我建议不要用。

2.3 参数化测试:从笨重到优雅

JUnit4 的参数化测试是我见过最不好用的特性之一。它要求测试类必须标@RunWith(Parameterized.class),然后提供一个public static Collection<Object[]> data()方法,再靠构造函数把参数注入进来:

@RunWith(Parameterized.class) public class CalculatorTest { @Parameters public static Collection<Object[]> data() { return Arrays.asList(new Object[][] { {1, 2, 3}, {2, 3, 5} }); } private int a; private int b; private int expected; public CalculatorTest(int a, int b, int expected) { this.a = a; this.b = b; this.expected = expected; } @Test public void testAdd() { assertEquals(expected, a + b); } }

参数一变多,类里的构造函数和字段就跟着爆炸,而且多个数据源组合时几乎没有招架之力。JUnit5 把这套东西整个重写了,直接用注解提供参数:

class CalculatorTest { @ParameterizedTest @CsvSource({ "1, 2, 3", "2, 3, 5", "10, -3, 7" }) void add(int a, int b, int expected) { assertEquals(expected, a + b); } }

再看一个用 MethodSource 的例子:

@ParameterizedTest @MethodSource("provideUserGroups") void check_group_members(String groupName, int expectedSize) { assertEquals(expectedSize, userService.groupSize(groupName)); } static Stream<Arguments> provideUserGroups() { return Stream.of( Arguments.of("admin", 3), Arguments.of("guest", 10) ); }

JUnit5 的参数化测试不再要求构造函数注入,参数直接映射到测试方法上。数据源可以是 @ValueSource、@CsvSource、@MethodSource,甚至可以自己写 ArgumentsProvider。老项目里那种“为了传三个参数写了一百行样板类”的情况,在 JUnit5 下基本绝迹了。

3. 迁移实操:照着做就完事的升级清单

迁移最怕的不是代码改不动,而是改完之后测试套件在无人察觉的情况下“缩水”了。所以我给的方案一定是从双引擎并行开始,而不是让你一次性把整个测试目录重写。

3.1 第一原则:先双轨运行

JUnit5 平台可以同时加载 Vintage 和 Jupiter 两个引擎。这意味着旧测试(import org.junit.Test)继续走 JUnit4 语义,新测试(import org.junit.jupiter.api.Test)走 JUnit5 语义。两边跑在同一个 Maven/Gradle 测试任务里,测试报告也能正常聚合。

我见过一些团队一上来就把所有测试文件的 import 全局替换成 JUnit5,结果 Spring 扩展、ExpectedException、TemporaryFolder 全部爆炸,整个迭代都在救火。正确姿势是:先加依赖、加引擎,让老测试原封不动继续跑;新写的测试一律按 Jupiter 风格来。跑两三个版本稳定之后,再做老代码的渐进迁移。

3.2 Maven和Gradle的配置修改

Maven 项目先升级依赖。以 JUnit 5.10.2 为例,在 pom.xml 里加这些:

<properties> <junit.version>5.10.2</junit.version> </properties> <dependencies> <!-- Jupiter核心:新注解、新断言、新扩展机制 --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>${junit.version}</version> <scope>test</scope> </dependency> <!-- Vintage引擎:让原有JUnit4测试继续运行 --> <dependency> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</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.2.5</version> </plugin> </plugins> </build>

关键点有两个。第一,surefire 版本至少 2.22.0,老版本的 surefire 根本不认识 JUnit Platform,你加了依赖它也不跑。第二,如果构建任务里设置了failIfNoTests=false,一旦 Vintage 忘记引入,老测试会静默消失,但构建依然显示成功,这是最危险的。

如果项目是 Gradle,配置更简单:

dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.10.2' } test { useJUnitPlatform() }

useJUnitPlatform()是 Gradle 对 JUnit5 的入口,必须加上,否则 Gradle 只会去找 JUnit4 的测试。

3.3 代码级迁移对照

实际改代码时,我一般把迁移拆成“import 更新”“方法签名更新”“断言更新”三小步。最典型的一套对照是这样的:

// JUnit4 写法 import org.junit.Test; import org.junit.Before; import org.junit.Ignore; import static org.junit.Assert.*; public class OrderServiceTest { @Before public void setUp() { // 初始化 } @Test(expected = IllegalArgumentException.class) public void should_throw_when_amount_negative() { orderService.create(-1); } @Ignore("待修复") @Test public void pending_test() { } }
// JUnit5 写法 import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class OrderServiceTest { @BeforeEach void setUp() { // 初始化 } @Test void should_throw_when_amount_negative() { IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> orderService.create(-1)); assertEquals("金额不能为负数", ex.getMessage()); } @Disabled("待修复") @Test void pending_test() { } }

需要注意的写法差异,我整理成了一张速查表:

  • @BeforeClass / @AfterClass:改为 @BeforeAll / @AfterAll。
  • @Before / @After:改为 @BeforeEach / @AfterEach。
  • @Ignore:改为 @Disabled。
  • @Test(expected = Xxx.class):删掉 expected,改成 assertThrows。
  • @Test(timeout = 1000):删掉 timeout,改成 @Timeout(value = 1, unit = TimeUnit.SECONDS) 或 assertTimeout。
  • @RunWith(XxxRunner.class):根据 runner 类型改写,Spring 对应 @ExtendWith(SpringExtension.class),Mockito 对应 @ExtendWith(MockitoExtension.class)。

3.4 一个稳妥的迁移顺序

如果你负责的是中型项目,我建议分四步走,每一步都要保证整个构建是绿的。

第一步,先把构建配置改好,双引擎跑一个迭代,旧测试数量不变,新测试开始用 Jupiter。这个阶段什么都不用改,只观察。

第二步,迁移与外部依赖无关的纯单元测试。这类测试只依赖 Mockito 和断言,改动风险最低,适合让团队练手。

第三步,迁移涉及容器、数据库、文件系统、异步任务的测试。这阶段要重点处理 @Rule、ExpectedException、TemporaryFolder,因为 JUnit5 对这三者的替代方案差异最大。

第四步,等 Jupiter 测试占比超过九成,并且你不再需要维护 JUnit4 测试时,可以去掉 Vintage 依赖。不过我的建议是:如果团队没有明确的时间压力,Vintage 留着也无大碍,它只是一层适配器,不会拖慢新测试的速度。

4. 性能实测:单线程、并行、内存开销到底差多少

“JUnit5 比 JUnit4 慢”这种说法在社区里流传很久,但大多数都是拍脑袋。真实情况需要实测。我用一个模拟订单系统的服务层测试套件做了对比,一共 1300 个用例,包含大量 Mockito 打桩、少量 H2 内存库读写,测试之间没有文件和端口冲突。

4.1 测试环境与测试集说明

  • 机器:8 核 16GB 的 Linux 开发机
  • JDK:17
  • 构建工具:Maven 3.9.x
  • JUnit4 版本:4.13.2
  • JUnit5 版本:5.10.2
  • 用例规模:1300 个方法级用例,绝大多数是快速执行的纯单元测试

测试策略是每个模式跑三次取中位数,尽量降低 JVM 温启动带来的误差。

4.2 单线程顺序执行实测

先看最传统的单线程执行结果:

指标JUnit4 4.13.2JUnit5 5.10.2
启动阶段耗时2.1 秒2.7 秒
用例执行阶段耗时40.5 秒39.1 秒
整体耗时42.6 秒41.8 秒
内存峰值1.52GB1.68GB
单用例平均耗时约 32.5ms约 31.4ms

结论很明确:单线程下,JUnit5 的用例执行效率和 JUnit4 基本持平,甚至略快一点。启动阶段 JUnit5 多了 Platform 初始化和引擎发现逻辑,所以比 JUnit4 多出约 0.6 秒。这点差距放在一个 40 秒的测试套件里完全无感。

内存方面 JUnit5 多吃了约 10% 的堆,主要是 Jupiter 的扩展机制和动态代理带来的额外对象消耗。如果你的测试套件只有几百个小用例,这个差距可以忽略;但如果单个模块跑到上百万次参数化用例,内存和 GC 开销就需要关注了。

4.3 并行执行实测

JUnit4 官方并没有提供并行执行的简单方案,你要么依赖 IDE 配置,要么引入第三方 Runner,稳定性很难保证。JUnit5 从平台层就支持并行,加一行配置就能开:

# src/test/resources/junit-platform.properties junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.default=concurrent junit.jupiter.execution.parallel.config.strategy=fixed junit.jupiter.execution.parallel.config.fixed.parallelism=8

同样这批 1300 个用例,并行模式下的数据:

执行模式耗时
JUnit4 单线程42.6 秒
JUnit5 单线程41.8 秒
JUnit5 并行 4 线程18.3 秒
JUnit5 并行 8 线程9.7 秒

8 线程跑下来接近线性加速比,42.6 秒压到 9.7 秒。这里有一点需要说明,我的测试集里用例之间没有共享静态状态,也没有数据库连接竞争,所以能跑出这个效果。如果你的测试里大量使用同一个 static 数据源、同一个临时目录,或者依赖执行顺序,并行会暴露出一堆随机失败,这个坑我在下一节展开。

如果你希望只给部分测试类开启并行,可以用@Execution(CONCURRENT)在类上标注,而不是全量开启。这是更精细的控制方式,适合逐步推广。

4.4 动态测试和参数化的开销分析

我额外测了动态测试。拿 @TestFactory 动态生成 200 条用例,再和普通 @Test 的 200 条用例对比,整体耗时差异几乎为零。动态测试每次都要创建 DynamicTest 实例,确实会产生小对象开销,但在百级和千级数据量下不构成瓶颈。只有到了十万级数据驱动场景,你才需要考虑用批量断言代替单条用例生成。

参数化测试方面,@CsvSource 和 @MethodSource 的启动开销比 JUnit4 的 Parameterized Runner 更低,因为 JUnit4 靠构造函数注入每次要反射创建测试实例,Jupiter 的方法参数映射更直接。实测中 500 条参数化用例的创建时间,JUnit5 比 JUnit4 快了大约 20%。

5. 迁移路上常见的坑:我踩过的和见过别人踩的

这一节是全文最想让你带走的部分。我在各种咨询场景里见到的迁移事故,基本集中在下面这几个问题上。

5.1 坑1:@Test(expected = ...) 在JUnit5里直接编译报错

JUnit5 的 @Test 注解没有 expected 和 timeout 属性。你把老的 @Test(expected = IllegalArgumentException.class) 直接改成 JUnit5 的 import 后,IDE 会立刻报错。很多人会下意识去查“JUnit5 怎么传 expected”,然后陷入焦虑。

正确做法是用 assertThrows。这里还有一个细节:assertThrows 会把捕获到的异常对象返回给你,你可以继续断言 message 或者嵌套原因。相比 JUnit4 那个只告诉你“有没有抛”的 expected,表达能力完全不是一个量级。

5.2 坑2:@Timeout 单位从毫秒变成了秒

JUnit4 里常写@Test(timeout = 1000),单位是毫秒。JUnit5 里对应的是@Timeout(1),默认单位是秒。如果你机械地把 1000 搬过来,等于把原来 1 秒的限制改成了 1000 秒,测试永远不会因为超时失败,这个错误很难被发现。

迁移时必须显式加时间单位:

@Timeout(value = 1000, unit = TimeUnit.MILLISECONDS) void should_finish_within_1s() { // ... }

5.3 坑3:断言消息参数顺序正好相反

这是最容易被全局替换脚本坑到的地方。

JUnit4 的写法是:

assertEquals("金额不匹配", expected, actual);

JUnit5 的写法是:

assertEquals(expected, actual, "金额不匹配");

消息从第一个参数挪到了最后一个参数。如果你用“把 org.junit.Assert 替换成 org.junit.jupiter.api.Assertions”这种方式做批量迁移,所有带消息的断言都会错位。轻则消息变成了期望值导致断言永远失败,重则明明是两个数值比较,编译器却提示你参数类型不匹配。迁移时建议分两步:先把不带消息的断言替换掉,再单独处理带消息的断言。

5.4 坑4:忘加 Vintage 引擎,测试静默消失

JUnit4 测试类 import 的是org.junit.Test。如果构建里只引入 junit-jupiter,没有引入 junit-vintage-engine,这些旧测试不会被 Jupiter 引擎识别,也不会被任何引擎加载。结果就是:mvn test 显示 BUILD SUCCESS,但实际执行的测试数量比之前少了一大截。

我在一个项目里亲眼见过,同事说“跑完了啊”,结果一看报告,700 个测试只有 200 个被执行,另外 500 个老测试全部“被消失”。在迁移初期,你至少要看一眼 surefire 的汇总行,确认 test 数量和基线一致。

5.5 坑5:@Rule 迁移到 JUnit5 无从下手

JUnit4 里最常见的两个 Rule 是 TemporaryFolder 和 ExpectedException。前者在 JUnit5 中用 @TempDir 替代,后者直接用 assertThrows。

临时目录的迁移很简单:

@TempDir Path tempDir; @Test void should_create_file_in_temp_dir() { Path dataFile = tempDir.resolve("data.csv"); // ... }

@TempDir 不需要手动清理,JUnit5 会在测试结束后自动删除。对比 JUnit4 的 TemporaryFolder,少了 afterEach 的清理代码,也少了临时目录残留的问题。

ExpectedException 则直接删掉。JUnit4 里你可能写过这样一段:

@Rule public ExpectedException thrown = ExpectedException.none(); @Test public void shouldThrow() { thrown.expect(IllegalArgumentException.class); thrown.expectMessage("金额不能为负数"); orderService.create(-1); }

到了 JUnit5,改成 assertThrows 加断言:

@Test void shouldThrow() { IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> orderService.create(-1)); assertEquals("金额不能为负数", ex.getMessage()); }

之所以不推荐继续用 Extension 模拟 ExpectedException,是因为 assertThrows 能让异常断言发生在触发点附近,代码更直观,而且不会出现“一个 Rule 被多个测试共享”的状态污染。

5.6 坑6:并行执行后的随机失败

并行测试的失败往往和被测代码无关,而是测试之间的隐式共享。最常见的是 static 字段、系统属性、当前工作目录、环境变量,以及统一的数据库连接池。

排查手法比较老套但有效:先跑一遍单线程,确定全绿;然后开启并行,失败后用-DreuseForks=false配合随机执行顺序去复现。如果失败用例每次都不同,几乎可以肯定是有资源共享。把共享资源改成每个线程独立的实例,或者给相关测试类加@Execution(SAME_THREAD)强制串行,问题就能解决。

6. 迁移后让测试更值钱:Jupiter的高级玩法

迁移完成不是终点。JUnit5 真正值钱的地方,在于它让测试套件的表达能力和可维护性上了一个台阶。下面几个是我在实际项目中觉得收益最大的特性。

6.1 @DisplayName:让测试报告变成人话

老测试的方法名往往是should_throw_when_amount_negative这样一串,虽然在 IDE 里能看懂,但到了 CI 报告里就变成一长串没有语义的方法签名。

JUnit5 可以给测试起一个可读的名字:

@Test @DisplayName("当库存不足时,下单应抛业务异常") void stock_not_enough() { // ... }

这带来的好处很难量化,但真实存在:产品经理偶尔会看 CI 失败报告,@DisplayName 能让他一眼看出是哪个业务场景挂了,比让团队去解释“should_throw_when_stock_not_enough”高效得多。

6.2 @Nested:让大型测试类按业务场景分层

JUnit4 里一个大型测试类几百行很常见,所有测试平铺在一个类里,想快速定位某个场景要么靠 IDE 的 outline,要么靠滚动。

JUnit5 的 @Nested 允许在一个外层测试类里组织内层测试类,执行时报告也会按树形展示:

@DisplayName("订单模块") class OrderServiceTest { @Nested @DisplayName("下单场景") class CreateOrderTest { @Test @DisplayName("库存不足时抛出异常") void stock_not_enough() { } @Test @DisplayName("金额为负数时抛出异常") void amount_negative() { } } @Nested @DisplayName("退款场景") class RefundTest { @Test @DisplayName("重复退款时应拒绝") void duplicate_refund() { } } }

这种结构对动辄一两百个测试用例的领域特别有用。配合 IDE 的折叠功能,你可以把整个模块的测试像看目录一样浏览。

6.3 Extension:替代Rule和Runner的自定义扩展

JUnit4 里要实现“每个测试跑之前打印日志、跑完之后清理数据”,要么写 Rule,要么写 Runner,还得考虑和 Spring 等其他 Runner 是否冲突。JUnit5 把它统一成了 Extension,实现一个回调接口就能挂载:

public class TimeLoggingExtension implements BeforeEachCallback, AfterEachCallback { private long start; @Override public void beforeEach(ExtensionContext context) { start = System.nanoTime(); } @Override public void afterEach(ExtensionContext context) { long costMs = (System.nanoTime() - start) / 1_000_000; System.out.println(context.getDisplayName() + " 耗时 " + costMs + " ms"); } }

用法:

@ExtendWith(TimeLoggingExtension.class) class OrderServiceTest { // ... }

这套机制的开放性比 JUnit4 高一个量级。Spring、Mockito、Testcontainers 这些库如今都基于 Extension 接入,理论上任何框架都可以通过编写 Extension 参与整个测试生命周期,不需要再去抢唯一的 Runner 位。

6.4 结合订单系统的动态测试示例

动态测试是我在数据驱动场景里用得最多的功能。假设需要校验一批用户账号是否都存在于系统里,传统做法是循环里套断言,循环一旦失败,后面的数据就全不跑了。用 @TestFactory 可以把每个数据点都变成独立用例:

@TestFactory Stream<DynamicTest> should_check_all_user_exists() { return Stream.of("user_001", "user_002", "user_003") .map(username -> dynamicTest("校验用户 " + username, () -> assertTrue(userService.exists(username)))); }

这样每个用户一个独立结果,CI 报告精确到具体是哪个账号断了。更重要的是,你不需要为每个账号写一个 @Test 方法,测试代码的重复度大幅下降。

回到开头那句话。JUnit5 对我来说不是“换个马甲的 JUnit4”,而是一套让测试在大型工程里活得下去的机制。性能上单线程跑几乎没有差异,但并行、分层报告、动态测试这些能力才是真正拉开体验差距的地方。如果你正被老测试套件困住,我的建议很简单:先加 Vintage,让新旧测试在同一个构建里共存一个月,再把新测试全部用 Jupiter 去写。不要指望一把梭,渐进迁移才是最稳的路。等你在报告里看到 @DisplayName 的中文场景名称和 @Nested 的树形层级之后,大概率就回不去了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询