本文为学习目的的个人翻译,译文及后文「译者解读」仅供参考。

原文链接: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,因此你不能同时使用例如 SpringJUnit4ClassRunnerParameterized。到了 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 和 ruleJUnit 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 4JUnit 5
@Test@Test
@Before@BeforeEach
@After@AfterEach
@BeforeClass@BeforeAll
@AfterClass@AfterAll
@Ignore@Disable
@Category@Tag

在多数情况下,我们只需要把包名和类名查找替换即可。

@Test 注解已经不再支持 expectedtimeout 这两个属性。

异常

@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 4JUnit 5
测试类包org.junitorg.junit.jupiter.api
断言类AssertAssertions
assertThat()Assert.assertThat()MatcherAssert.assertThat()
可选断言消息第一个方法参数最后一个方法参数
假设类AssumeAssumptions
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

这部分变化与断言类似:提示消息也变成了最后一个参数。

@Test
public 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 继续沿用:

@EnableRuleMigrationSupport
class 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

@EnableRuleMigrationSupport
class JUnit5TemporaryFolderTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
void shouldCreateNewFile() throws IOException {
File textFile = temporaryFolder.newFile("test.txt");
Assertions.assertNotNull(textFile);
}
}

若想彻底摆脱旧 rule,则需要改用 TempDirectory 扩展。

可以通过 @TempDir 注解修饰 PathFile 字段:

class JUnit5TemporaryFolderTest {
@TempDir
Path temporaryDirectory;
@Test
public void shouldCreateNewFile() {
Path textFile = temporaryDirectory.resolve("test.txt");
Assertions.assertNotNull(textFile);
}
}

这个扩展和之前的 rule 很接近。但有一个区别是:它也可以直接用于方法参数:

@Test
public void shouldCreateNewFile(@TempDir Path anotherDirectory) {
Path textFile = anotherDirectory.resolve("test.txt");
Assertions.assertNotNull(textFile);
}

自定义 Rule

迁移自定义 JUnit 4 rule,需要把代码重写为 JUnit 5 扩展。

如果一个逻辑原本是通过 @Rule 应用的,那么通常可以通过实现 BeforeEachCallbackAfterEachCallback 来重现。

例如,假设我们有一个用于性能日志记录的 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 应用的,则可以通过实现 BeforeAllCallbackAfterAllCallback 来迁移。

有些情况下,我们会把 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 上找到。

译者解读

  1. 这篇文章的重点不是单纯列一个“替换清单”,而是强调 JUnit 5 提供了渐进式迁移路径。如果代码库里已经有大量 JUnit 4 测试,那么能否并行运行旧测试与新测试,实际上决定了迁移是否可控。

  2. 文中把迁移拆成依赖、注解、测试类与方法、runner、rule、参数化测试几块,这种组织方式本身就很值得参考。它对应的并不是“语法升级”,而是测试框架从旧模型向新模型的整体迁移。

  3. 需要特别注意的是,runner 和 rule 的替换,往往比简单的注解或方法替换更费时。因为这部分通常已经和项目的测试基础设施、Spring 集成、Mockito 使用方式,甚至自定义测试框架耦合在一起。

  4. 作者提到的 junit-jupiter-migrationsupport 很适合作为过渡手段,但不应被当成长期终点。它的价值在于降低一次性重写成本,而不是让旧写法永久留在代码库里。

  5. 参数化测试这一段也值得单独留意。JUnit 5 虽然没有对 JUnit 4 Parameterized runner 的直接平移替代,但它给出的 @ParameterizedTest + @MethodSource 组合,其实更灵活,也更符合现代 Java 测试代码的组织方式。