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

原文链接: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 5 相比 JUnit 4 的一些优势。

JUnit 4 最大的缺陷之一,是它不支持多个 runner(因此你不能同时使用例如 SpringJUnit4ClassRunnerParameterized)。而在 JUnit 5 中,这终于可以通过注册多个扩展来实现。

此外,JUnit 5 利用了 Java 8 的能力,例如用 lambda 做惰性求值。JUnit 4 一直停留在 Java 7 时代,因此错过了 Java 8 的很多特性。

同时,JUnit 4 在参数化测试方面也有短板,而且缺少嵌套测试。这促使第三方开发者为这些场景提供专门的 runner。

JUnit 5 改善了参数化测试支持,也原生支持嵌套测试,还加入了一些其他新特性。

关键迁移步骤

借助 JUnit Vintage test engine,JUnit 提供了一条渐进式迁移路径。我们可以使用 JUnit Vintage test engine,在 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 engine 引入了额外依赖。
替换注解一些 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 中的 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.junit测试类包:org.junit.jupiter.api
断言类:Assert断言类:Assertions
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 不再像 JUnit 4 那样提供 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 4 中的 @Category 注解,在 JUnit 5 中已经被 @Tag 注解取代。同时,我们也不再使用 marker interface,而是直接向注解传一个字符串参数。

在 JUnit 4 中,我们通过 marker interface 使用 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 中这样配置 categories:

test {
useJUnit {
includeCategories 'com.example.AcceptanceTest'
excludeCategories 'com.example.IntegrationTest'
}
}

但在 JUnit 5 中,我们使用的是 tag:

@Tag("integration")
class JUnit5TagTest {}

Maven pom.xml 中的配置也会更简单一些:

<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<groups>acceptance</groups>
<excludedGroups>integration</excludedGroups>
</configuration>
</plugin>

相应地,build.gradle 中的配置也更轻量:

test {
useJUnitPlatform {
includeTags 'acceptance'
excludeTags 'integration'
}
}

运行器

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 的 pom.xml 中,需要添加:

<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 的 build.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 extension。

为了使用 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();
}
}

规则

JUnit 4 中的 @Rule@ClassRule 注解,在 JUnit 5 中已经不存在了。要实现相同功能,我们仍然需要使用位于 org.junit.jupiter.api.extension 包中的新扩展模型,以及 @ExtendWith 注解。

不过,为了提供一条渐进式迁移路径,junit-jupiter-migrationsupport 模块支持一部分 JUnit 4 rule 及其子类:

  • ExternalResource(包括 TemporaryFolder 等)
  • Verifier(包括 ErrorCollector 等)
  • ExpectedException

如果现有代码使用了这些 rule,那么通过在类级别加上 org.junit.jupiter.migrationsupport.rules 包中的 @EnableRuleMigrationSupport 注解,就可以先保持代码不变。

在 Maven 中,需要在 pom.xml 中加入:

<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-migrationsupport</artifactId>
<version>5.8.0</version>
</dependency>
</dependencies>

在 Gradle 中,则在 build.gradle 中加入:

dependencies {
testImplementation('org.junit.jupiter:junit-jupiter-migrationsupport:5.8.0')
}

期望异常

在 JUnit 4 中,使用 @Test(expected = SomeException.class) 时,我们无法检查异常的更多细节。

如果想检查异常消息等内容,就需要使用 ExpectedException rule。

JUnit 5 的 migration support 允许我们继续保留这种写法,只要在测试类上加上 @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,那么启用 migration support 可以作为一种有效的过渡步骤。

不过,要想真正完成迁移到 JUnit 5,我们最终还是得去掉这条 rule,并用 assertThrows() 方法替换它:

class JUnit5ExpectedExceptionTest {
@Test
void catchThrownExceptionAndMessage() {
Throwable thrown = assertThrows(IllegalArgumentException.class, () -> {
throw new IllegalArgumentException("Wrong argument!");
});
assertEquals("Wrong argument!", thrown.getMessage());
}
}

这样写出来的结果可读性会高很多,因为所有信息都集中在同一个地方。

临时目录

在 JUnit 4 中,我们可以用 TemporaryFolder rule 来创建并清理临时目录。

同样地,JUnit 5 的 migration support 允许我们只通过加上 @EnableRuleMigrationSupport 注解,就先继续保留这段代码:

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

要想在 JUnit 5 中彻底摆脱这条 rule,就需要用 TempDirectory extension 来替代它。

我们可以通过给 PathFile 类型字段加上 @TempDir 注解来使用这个扩展:

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);
}

自定义规则

迁移自定义 JUnit 4 rule,意味着必须把原有代码重写成 JUnit 5 extension。

通过实现 BeforeEachCallbackAfterEachCallback 接口,我们可以复现原先通过 @Rule 应用的规则逻辑。

比如,如果我们有一个用于记录性能的 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 中,可以把同样的逻辑写成 extension:

@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
}
}

自定义类规则

类似地,我们也可以通过实现 BeforeAllCallbackAfterAllCallback 接口,来复现原先通过 @ClassRule 应用的规则逻辑。

有些情况下,我们可能在 JUnit 4 中把类规则写成一个内部匿名类。下面的示例中,我们有一个 server resource,希望它能方便地被不同测试复用:

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());
}
}

我们可以把这条 rule 写成 JUnit 5 extension。不过,如果只是通过 @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 中,我们有了 @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 5 Parameterized Tests 教程。

总结

从 JUnit 4 迁移到 JUnit 5,需要多少工作量,取决于现有测试是如何编写的。

  • 我们可以让 JUnit 4 测试与 JUnit 5 测试并行运行,从而支持渐进式迁移。
  • 在很多情况下,我们只需要查找并替换包名和类名。
  • 我们可能需要把自定义 runner 和 rule 转换成 extension。
  • 要迁移参数化测试,则可能需要做一定程度的重构。

本指南的示例代码可以在 GitHub 上找到。

译者总结

这篇文章的重点,是把 JUnit 4 到 JUnit 5 的迁移拆解成几类最常见的改动:依赖、注解、测试类与方法、分类、runner、rule 和参数化测试。原文强调的不是“一步重写完”,而是借助 JUnit Vintage 实现渐进式迁移。

其中最值得注意的变化,是 JUnit 5 把原来 runner 和 rule 的分散模型统一成了 extension 模型。这也是为什么文章后半部分花了较多篇幅去讲 Spring、Mockito、自定义 rule 和类级 rule 的迁移方式。

另一个容易忽略的点是,很多迁移并不是语法层面的简单替换。比如 @Test(expected = ...)assertThrows()TemporaryFolder@TempDirParameterized runner 到 @ParameterizedTest,本质上都伴随着测试写法和组织方式的变化。

原文最后给出的结论也比较克制:有些地方只要替换包名和类名即可,但 runner、rule 和参数化测试往往会更耗时。因此,这篇文章更适合作为迁移清单和决策参考,而不是把所有改造都理解成一次机械替换。