本文为学习目的的个人翻译,译文及后文「译者解读」仅供参考。
原文链接:Migrating From JUnit 4 to JUnit 5: A Definitive Guide。
版权归原作者或原刊登方所有;若认为不妥,请联系删除。本文为非官方译本。
在这篇文章中,我们将看看从 JUnit 4 迁移到 JUnit 5 需要经历哪些步骤。我们还会看到,如何在迁移过程中同时运行旧测试与新测试,以及迁移代码时需要做出哪些改动。
概览
JUnit 5 与之前版本不同,采用了模块化设计。新架构的关键点,是把测试编写、扩展机制和工具支持这几类关注点拆开。
JUnit 被拆分为三个子项目:
- JUnit Platform:作为基础层,提供构建插件,以及编写测试引擎所需的 API
- JUnit Jupiter:JUnit 5 中用于编写测试和扩展的新 API
- JUnit Vintage:允许我们在 JUnit 5 下继续运行 JUnit 4 测试
相比 JUnit 4,JUnit 5 有几个明显优势。
JUnit 4 最大的问题之一,是不支持多个 runner,因此你不能同时使用例如 SpringJUnit4ClassRunner 和 Parameterized。到了 JUnit 5,这件事终于可以通过注册多个扩展来实现。
另外,JUnit 5 利用了 Java 8 的能力,例如用 lambda 做惰性求值。JUnit 4 一直停留在 Java 7 时代,没能吃到 Java 8 的特性红利。
同时,JUnit 4 在参数化测试方面也有明显短板,而且缺少嵌套测试。这促使第三方开发者为这些场景提供专门的 runner。
JUnit 5 改善了参数化测试支持,也原生支持嵌套测试,还加入了一些其他新特性。
关键迁移步骤
借助 JUnit Vintage 测试引擎,JUnit 提供了一条渐进式迁移路径。我们可以使用 JUnit Vintage,在 JUnit 5 下运行 JUnit 4 测试。
所有 JUnit 4 专属类都位于 org.junit 包中;所有 JUnit 5 专属类都位于 org.junit.jupiter 包中。如果类路径中同时存在 JUnit 4 和 JUnit 5,它们之间不会发生冲突。
因此,在迁移完全结束之前,我们可以继续保留既有的 JUnit 4 测试,同时新增 JUnit 5 测试。也正因为如此,迁移可以按阶段逐步推进。
下面这张表总结了从 JUnit 4 迁移到 JUnit 5 的关键步骤:
| 步骤 | 说明 |
|---|---|
| 替换依赖 | JUnit 4 通常只需要单个依赖;JUnit 5 为迁移支持和 JUnit Vintage 引擎引入了额外依赖 |
| 替换注解 | JUnit 5 中有些注解与 JUnit 4 同名,但也有一些新注解取代旧注解,且行为略有不同 |
| 替换测试类与方法 | 断言和假设被移动到了新类中;某些方法的参数顺序也有所变化 |
| 用扩展替换 runner 和 rule | JUnit 5 只有统一的扩展模型,不再区分 runner 和 rule;这一步通常比其他步骤更耗时 |
接下来,我们逐项展开。
依赖
先看看,要在新平台上运行现有测试,需要做什么。
如果要同时运行 JUnit 4 和 JUnit 5 测试,我们需要:
- 用 JUnit Jupiter 编写并运行 JUnit 5 测试
- 用 Vintage test engine 运行 JUnit 4 测试
除此之外,如果你使用 Maven 运行测试,还需要 Surefire 插件。也就是说,需要在 pom.xml 中加入这些依赖:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>2.22.2</version></plugin>
<dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.8.0</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> <version>5.8.0</version> <scope>test</scope> </dependency></dependencies>同样地,如果使用 Gradle,也需要在测试任务中启用 JUnit Platform,并把相关依赖加入 build.gradle:
test { useJUnitPlatform()}
dependencies { testImplementation('org.junit.jupiter:junit-jupiter:5.8.0') testRuntime('org.junit.vintage:junit-vintage-engine:5.8.0')}注解
注解现在位于 org.junit.jupiter.api 包,而不再是 org.junit。
大多数注解的名字也发生了变化:
| JUnit 4 | JUnit 5 |
|---|---|
@Test | @Test |
@Before | @BeforeEach |
@After | @AfterEach |
@BeforeClass | @BeforeAll |
@AfterClass | @AfterAll |
@Ignore | @Disable |
@Category | @Tag |
在多数情况下,我们只需要把包名和类名查找替换即可。
但 @Test 注解已经不再支持 expected 和 timeout 这两个属性。
异常
@Test 注解不再支持 expected 属性。
JUnit 4 中的 expected,可以用 JUnit 5 的 assertThrows() 来替代:
public class JUnit4ExceptionTest { @Test(expected = IllegalArgumentException.class) public void shouldThrowAnException() { throw new IllegalArgumentException(); }}
class JUnit5ExceptionTest { @Test void shouldThrowAnException() { Assertions.assertThrows(IllegalArgumentException.class, () -> { throw new IllegalArgumentException(); }); }}超时
@Test 注解同样也不再支持 timeout 属性。
JUnit 4 中的 timeout,可以用 JUnit 5 的 assertTimeout() 来替代:
public class JUnit4TimeoutTest { @Test(timeout = 1) public void shouldTimeout() throws InterruptedException { Thread.sleep(5); }}
class JUnit5TimeoutTest { @Test void shouldTimeout() { Assertions.assertTimeout(Duration.ofMillis(1), () -> Thread.sleep(5)); }}测试类与方法
前面已经提到,断言和假设都被移动到了新类中。此外,有些方法的参数顺序也发生了变化。
下面这张表总结了 JUnit 4 与 JUnit 5 在测试类和方法上的主要差异:
| 项目 | JUnit 4 | JUnit 5 |
|---|---|---|
| 测试类包 | org.junit | org.junit.jupiter.api |
| 断言类 | Assert | Assertions |
assertThat() | Assert.assertThat() | MatcherAssert.assertThat() |
| 可选断言消息 | 第一个方法参数 | 最后一个方法参数 |
| 假设类 | Assume | Assumptions |
assumeNotNull() | 存在 | 已移除 |
assumeNoException() | 存在 | 已移除 |
还有一点值得注意:在 JUnit 4 中,我们自己编写的测试类和方法必须是 public。
JUnit 5 去掉了这个限制,测试类和测试方法都可以是 package-private。文中的示例都体现了这一点。
下面分别看断言和假设的变化。
断言
断言方法现在位于 org.junit.jupiter.api.Assertions 类中,而不再是 org.junit.Assert。
很多时候,只要替换包名即可。
但如果断言里带了自定义消息,就会遇到编译错误,因为断言消息现在变成了最后一个参数。这样的顺序其实更自然:
public class JUnit4AssertionTest { @Test public void shouldFailWithMessage() { Assert.assertEquals("numbers " + 1 + " and " + 2 + " are not equal", 1, 2); }}
class JUnit5AssertionTest { @Test void shouldFailWithMessage() { Assertions.assertEquals(1, 2, () -> "numbers " + 1 + " and " + 2 + " are not equal"); }}上面的例子还展示了另一点:断言消息可以惰性求值,这样就避免了不必要的复杂字符串构造。
注意:如果断言对象本身是
String,而你又传了自定义消息,那么因为所有参数都是String类型,编译器未必会报错。 这类问题通常只能在运行测试时通过失败结果看出来。
另外,有些旧测试会通过 JUnit 4 的 Assert.assertThat() 使用 Hamcrest 断言。JUnit 5 不再提供 Assertions.assertThat(),因此需要改为从 Hamcrest 的 MatcherAssert 导入:
public class JUnit4HamcrestTest { @Test public void numbersNotEqual() { Assert.assertThat("numbers 1 and 2 are not equal", 1, is(not(equalTo(2)))); }}
class JUnit5HamcrestTest { @Test void numbersNotEqual() { MatcherAssert.assertThat("numbers 1 and 2 are not equal", 1, is(not(equalTo(2)))); }}假设
假设方法现在位于 org.junit.jupiter.Assumptions 类中,而不是 org.junit.Assume。
这部分变化与断言类似:提示消息也变成了最后一个参数。
@Testpublic class JUnit4AssumptionTest { public void shouldOnlyRunInDevelopmentEnvironment() { Assume.assumeTrue("Aborting: not on developer workstation", "DEV".equals(System.getenv("ENV"))); }}
class JUnit5AssumptionTest { @Test void shouldOnlyRunInDevelopmentEnvironment() { Assumptions.assumeTrue("DEV".equals(System.getenv("ENV")), () -> "Aborting: not on developer workstation"); }}还要注意,Assume.assumeNotNull() 和 Assume.assumeNoException() 在 JUnit 5 中已经没有对应方法了。
分类
JUnit 4 的 @Category 注解,在 JUnit 5 中被 @Tag 取代。
同时,也不再使用 marker interface,而是直接给注解传入一个字符串。
在 JUnit 4 中,我们通常会这样写 category:
public interface IntegrationTest {}
@Category(IntegrationTest.class)public class JUnit4CategoryTest {}然后在 Maven 的 pom.xml 中按 category 过滤:
<plugin> <artifactId>maven-surefire-plugin</artifactId> <version>2.22.2</version> <configuration> <groups>com.example.AcceptanceTest</groups> <excludedGroups>com.example.IntegrationTest</excludedGroups> </configuration></plugin>如果用 Gradle,则在 build.gradle 中配置:
test { useJUnit { includeCategories 'com.example.AcceptanceTest' excludeCategories 'com.example.IntegrationTest' }}到了 JUnit 5,写法变成:
@Tag("integration")class JUnit5TagTest {}Maven 中的配置也更简单:
<plugin> <artifactId>maven-surefire-plugin</artifactId> <version>2.22.2</version> <configuration> <groups>acceptance</groups> <excludedGroups>integration</excludedGroups> </configuration></plugin>对应地,Gradle 中的配置也更直接:
test { useJUnitPlatform { includeTags 'acceptance' excludeTags 'integration' }}Runner
JUnit 4 中的 @RunWith 注解,在 JUnit 5 中已经不存在了。要实现同样的效果,需要使用 org.junit.jupiter.api.extension 包中的扩展模型,以及 @ExtendWith 注解。
Spring Runner
JUnit 4 中常见的一个 runner 是 Spring test runner。迁移到 JUnit 5 后,需要把 runner 换成 Spring extension。
如果你使用的是 Spring 5,这个扩展已经包含在 Spring Test 中:
@RunWith(SpringJUnit4ClassRunner.class)@ContextConfiguration(classes = SpringTestConfiguration.class)public class JUnit4SpringTest {
}
@ExtendWith(SpringExtension.class)@ContextConfiguration(classes = SpringTestConfiguration.class)class JUnit5SpringTest {
}但如果你还在使用 Spring 4,那么它并不会自带 SpringExtension。这时仍然可以使用,只是需要额外从 JitPack 仓库添加依赖。
Maven 配置如下:
<repositories> <repository> <id>jitpack.io</id> <url>https://jitpack.io</url> </repository></repositories>
<dependencies> <dependency> <groupId>com.github.sbrannen</groupId> <artifactId>spring-test-junit5</artifactId> <version>1.5.0</version> <scope>test</scope> </dependency></dependencies>Gradle 配置如下:
repositories { mavenCentral() maven { url 'https://jitpack.io' }}
dependencies { testImplementation('com.github.sbrannen:spring-test-junit5:1.5.0')}Mockito Runner
JUnit 4 中另一个常见 runner 是 Mockito runner。迁移到 JUnit 5 后,需要改用 Mockito 的 JUnit 5 扩展。
如果要启用 Mockito extension,需要在 Maven 的 pom.xml 中添加 mockito-junit-jupiter:
<dependency> <groupId>org.mockito</groupId> <artifactId>mockito-junit-jupiter</artifactId> <version>3.6.28</version> <scope>test</scope></dependency>如果使用 Gradle,则在 build.gradle 中添加:
dependencies { testImplementation('org.mockito:mockito-junit-jupiter:3.12.4')}然后就可以把 MockitoJUnitRunner 改成 MockitoExtension:
@RunWith(MockitoJUnitRunner.class)public class JUnit4MockitoTest {
@InjectMocks private Example example;
@Mock private Dependency dependency;
@Test public void shouldInjectMocks() { example.doSomething(); verify(dependency).doSomethingElse(); }}
@ExtendWith(MockitoExtension.class)class JUnit5MockitoTest {
@InjectMocks private Example example;
@Mock private Dependency dependency;
@Test void shouldInjectMocks() { example.doSomething(); verify(dependency).doSomethingElse(); }}Rule
JUnit 4 中的 @Rule 和 @ClassRule,在 JUnit 5 中也不存在了。相同能力需要通过新的扩展模型和 @ExtendWith 实现。
不过,为了支持渐进迁移,junit-jupiter-migrationsupport 模块仍然兼容一部分 JUnit 4 rule 及其子类,包括:
ExternalResource(例如TemporaryFolder)Verifier(例如ErrorCollector)ExpectedException
如果已有代码使用这些 rule,可以通过类级别注解 @EnableRuleMigrationSupport,先不改代码继续运行。
要在 Maven 中启用,需要添加:
<dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-migrationsupport</artifactId> <version>5.8.0</version> </dependency></dependencies>在 Gradle 中则添加:
dependencies { testImplementation('org.junit.jupiter:junit-jupiter-migrationsupport:5.8.0')}期望异常
在 JUnit 4 中,@Test(expected = SomeException.class) 不能检查异常的细节。
如果还想断言异常消息,就必须使用 ExpectedException rule。
在 JUnit 5 的迁移支持下,可以通过 @EnableRuleMigrationSupport 继续沿用:
@EnableRuleMigrationSupportclass JUnit5ExpectedExceptionTest {
@Rule public ExpectedException thrown = ExpectedException.none();
@Test void catchThrownExceptionAndMessage() { thrown.expect(IllegalArgumentException.class); thrown.expectMessage("Wrong argument");
throw new IllegalArgumentException("Wrong argument!"); }}如果这类 rule 在代码库里用得很多,这可以作为一种过渡手段。
但要彻底迁移到 JUnit 5,最终还是需要移除 rule,改写为 assertThrows():
class JUnit5ExpectedExceptionTest {
@Test void catchThrownExceptionAndMessage() { Throwable thrown = assertThrows(IllegalArgumentException.class, () -> { throw new IllegalArgumentException("Wrong argument!"); });
assertEquals("Wrong argument!", thrown.getMessage()); }}这样写的可读性更高,因为所有逻辑都集中在一个地方。
Temporary Folder
在 JUnit 4 中,我们可以用 TemporaryFolder rule 创建并清理临时目录。
同样地,借助 JUnit 5 的迁移支持,我们只需增加 @EnableRuleMigrationSupport:
@EnableRuleMigrationSupportclass JUnit5TemporaryFolderTest {
@Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test void shouldCreateNewFile() throws IOException { File textFile = temporaryFolder.newFile("test.txt"); Assertions.assertNotNull(textFile); }}若想彻底摆脱旧 rule,则需要改用 TempDirectory 扩展。
可以通过 @TempDir 注解修饰 Path 或 File 字段:
class JUnit5TemporaryFolderTest {
@TempDir Path temporaryDirectory;
@Test public void shouldCreateNewFile() { Path textFile = temporaryDirectory.resolve("test.txt"); Assertions.assertNotNull(textFile); }}这个扩展和之前的 rule 很接近。但有一个区别是:它也可以直接用于方法参数:
@Testpublic void shouldCreateNewFile(@TempDir Path anotherDirectory) { Path textFile = anotherDirectory.resolve("test.txt"); Assertions.assertNotNull(textFile);}自定义 Rule
迁移自定义 JUnit 4 rule,需要把代码重写为 JUnit 5 扩展。
如果一个逻辑原本是通过 @Rule 应用的,那么通常可以通过实现 BeforeEachCallback 和 AfterEachCallback 来重现。
例如,假设我们有一个用于性能日志记录的 JUnit 4 rule:
public class JUnit4PerformanceLoggerTest {
@Rule public PerformanceLoggerRule logger = new PerformanceLoggerRule();}
public class PerformanceLoggerRule implements TestRule {
@Override public Statement apply(Statement base, Description description) { return new Statement() { @Override public void evaluate() throws Throwable { // Store launch time base.evaluate(); // Store elapsed time } }; }}在 JUnit 5 中,可以改写成扩展:
@ExtendWith(PerformanceLoggerExtension.class)public class JUnit5PerformanceLoggerTest {
}
public class PerformanceLoggerExtension implements BeforeEachCallback, AfterEachCallback {
@Override public void beforeEach(ExtensionContext context) throws Exception { // Store launch time }
@Override public void afterEach(ExtensionContext context) throws Exception { // Store elapsed time }}自定义 Class Rule
类似地,如果原先逻辑是通过 @ClassRule 应用的,则可以通过实现 BeforeAllCallback 和 AfterAllCallback 来迁移。
有些情况下,我们会把 class rule 写成 JUnit 4 中的内部匿名类。下面这个例子里,我们有一个希望在多个测试中复用的 server 资源:
public class JUnit4ServerBaseTest { static Server server = new Server(9000);
@ClassRule public static ExternalResource resource = new ExternalResource() { @Override protected void before() throws Throwable { server.start(); }
@Override protected void after() { server.stop(); } };}
public class JUnit4ServerInheritedTest extends JUnit4ServerBaseTest { @Test public void serverIsRunning() { Assert.assertTrue(server.isRunning()); }}在 JUnit 5 中,我们可以把它写成扩展。不过,如果只用 @ExtendWith,就没法直接访问扩展里提供的资源。因此这里更适合使用 @RegisterExtension:
public class ServerExtension implements BeforeAllCallback, AfterAllCallback { private Server server = new Server(9000);
public Server getServer() { return server; }
@Override public void beforeAll(ExtensionContext context) throws Exception { server.start(); }
@Override public void afterAll(ExtensionContext context) throws Exception { server.stop(); }}
class JUnit5ServerTest { @RegisterExtension static ServerExtension extension = new ServerExtension();
@Test void serverIsRunning() { Assertions.assertTrue(extension.getServer().isRunning()); }}参数化测试
在 JUnit 4 中,要写参数化测试,通常需要使用 Parameterized runner。同时,还需要通过 @Parameterized.Parameters 标注的方法来提供测试数据:
@RunWith(Parameterized.class)public class JUnit4ParameterizedTest { @Parameterized.Parameters public static Collection<Object[]> data() { return Arrays.asList(new Object[][] { { 1, 1 }, { 2, 1 }, { 3, 2 }, { 4, 3 }, { 5, 5 }, { 6, 8 } }); }
private int input; private int expected;
public JUnit4ParameterizedTest(int input, int expected) { this.input = input; this.expected = expected; }
@Test public void fibonacciSequence() { assertEquals(expected, Fibonacci.compute(input)); }}编写 JUnit 4 参数化测试存在不少不足,因此也出现了像 JUnitParams 这样的社区 runner,它甚至把自己描述成“没那么难用的参数化测试”。
遗憾的是,JUnit 4 的参数化 runner 在 JUnit 5 中没有一个完全一一对应的直接替代品。
在 JUnit 5 中,取而代之的是 @ParameterizedTest 注解。测试数据可以通过多种数据源注解提供,其中最接近 JUnit 4 旧写法的是 @MethodSource:
class JUnit5ParameterizedTest { private static Stream<Arguments> data() { return Stream.of( Arguments.of(1, 1), Arguments.of(2, 1), Arguments.of(3, 2), Arguments.of(4, 3), Arguments.of(5, 5), Arguments.of(6, 8) ); }
@ParameterizedTest @MethodSource("data") void fibonacciSequence(int input, int expected) { assertEquals(expected, Fibonacci.compute(input)); }}也就是说,在 JUnit 5 中,最接近 JUnit 4 参数化测试的写法,就是把 @ParameterizedTest 与 @MethodSource 搭配使用。
不过,JUnit 5 的参数化测试相对 JUnit 4 已经有了不少改进。作者在另一篇文章 JUnit 5 Parameterized Tests 中对这些改进做了更详细说明。
总结
从 JUnit 4 迁移到 JUnit 5,工作量会因现有测试写法的不同而有所差异。
- 我们可以同时运行 JUnit 4 测试与 JUnit 5 测试,以支持渐进式迁移。
- 在很多情况下,只要替换包名和类名即可。
- 自定义 runner 和 rule 可能需要转换为 extension。
- 参数化测试的迁移,往往需要一定程度的重构。
本指南对应的示例代码可在 GitHub 上找到。
译者解读
这篇文章的重点不是单纯列一个“替换清单”,而是强调 JUnit 5 提供了渐进式迁移路径。如果代码库里已经有大量 JUnit 4 测试,那么能否并行运行旧测试与新测试,实际上决定了迁移是否可控。
文中把迁移拆成依赖、注解、测试类与方法、runner、rule、参数化测试几块,这种组织方式本身就很值得参考。它对应的并不是“语法升级”,而是测试框架从旧模型向新模型的整体迁移。
需要特别注意的是,runner 和 rule 的替换,往往比简单的注解或方法替换更费时。因为这部分通常已经和项目的测试基础设施、Spring 集成、Mockito 使用方式,甚至自定义测试框架耦合在一起。
作者提到的
junit-jupiter-migrationsupport很适合作为过渡手段,但不应被当成长期终点。它的价值在于降低一次性重写成本,而不是让旧写法永久留在代码库里。参数化测试这一段也值得单独留意。JUnit 5 虽然没有对 JUnit 4
Parameterizedrunner 的直接平移替代,但它给出的@ParameterizedTest+@MethodSource组合,其实更灵活,也更符合现代 Java 测试代码的组织方式。