本文为学习目的的个人翻译,译文及后文「译者总结」仅供参考。
原文链接: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(因此你不能同时使用例如 SpringJUnit4ClassRunner 和 Parameterized)。而在 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 和 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 中的 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() | 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 类。
这些方法和断言有类似变化:假设消息现在也变成了最后一个参数:
@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 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 注解:
@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,那么启用 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 注解,就先继续保留这段代码:
@EnableRuleMigrationSupportclass 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 来替代它。
我们可以通过给 Path 或 File 类型字段加上 @TempDir 注解来使用这个扩展:
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);}自定义规则
迁移自定义 JUnit 4 rule,意味着必须把原有代码重写成 JUnit 5 extension。
通过实现 BeforeEachCallback 和 AfterEachCallback 接口,我们可以复现原先通过 @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 }}自定义类规则
类似地,我们也可以通过实现 BeforeAllCallback 和 AfterAllCallback 接口,来复现原先通过 @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 到 @TempDir、Parameterized runner 到 @ParameterizedTest,本质上都伴随着测试写法和组织方式的变化。
原文最后给出的结论也比较克制:有些地方只要替换包名和类名即可,但 runner、rule 和参数化测试往往会更耗时。因此,这篇文章更适合作为迁移清单和决策参考,而不是把所有改造都理解成一次机械替换。