[译]从JUnit4迁移到JUnit5:权威指南

在本文中,我们将了解从 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 最大的缺陷之一是它不支持多个运行器(因此您不能同时使用,例如 SpringJUnit4ClassRunnerParameterized)。在 JUnit 5 中,这终于可以通过注册多个扩展来实现。

此外,JUnit 5 利用 Java 8 的特性,例如 lambda 表达式进行惰性求值。JUnit 4 从未超越 Java 7,缺少 Java 8 的特性。

此外,JUnit 4 在参数化测试方面存在缺陷,并且缺少嵌套测试。这启发了第三方开发人员针对这些情况创建专门的运行器。

JUnit 5 增加了对参数化测试的更好支持和对嵌套测试的本机支持以及一些其他新功能。

关键迁移步骤

JUnit 在 JUnit Vintage 测试引擎的帮助下提供了一条逐步迁移的路径。我们可以使用 JUnit Vintage 测试引擎将 JUnit 4 测试与 JUnit 5 一起运行。

所有特定于 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 相同,但有一些新的注解取代了旧的注解,并且功能略有不同。
替换测试类和方法断言和假设已移至新类。在某些情况下,方法参数顺序会有所不同。
用扩展程序替换运行器和规则JUnit 5 只有一个扩展模型,没有运行器和规则。此步骤可能比其他步骤花费更多时间。

接下来我们将深入研究每个步骤。

依赖

让我们看看在新平台上运行现有测试需要做什么。为了运行 JUnit 4 和 JUnit 5 测试,我们需要:

  • JUnit Jupiter 用于编写和运行 JUnit 5 测试
  • 用于运行 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属性。

异常

我们不能再将expected属性与@Test注解一起使用。

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

超时

我们不能再将timeout属性与@Test注解一起使用。

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.junitorg.junit.jupiter.api
断言类AssertAssertions
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 4Assert.assertThat()方法提供的 Hamcrest 断言的旧测试。JUnit 5Assertions.assertThat()不像 JUnit 4 那样提供方法。相反,我们必须从 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()norAssume.assumeNoException()了。

类别

JUnit 4 中的注解@Category已被 JUnit 5 中的 @Tag 注解取代。此外,我们不再使用标记接口,而是向注解传递字符串参数。

在 JUnit 4 中,我们使用带有标记接口的类别:

public interface IntegrationTest {}

@Category(IntegrationTest.class)
public class JUnit4CategoryTest {}

然后我们可以在 Maven 中按标签配置测试过滤pom.xml

<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 中的配置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'
    }
}

Runners

JUnit 4 中的注解 @RunWith 在 JUnit 5 中不存在。我们可以通过使用org.junit.jupiter.api.extension包和@ExtendWith注解中的新扩展模型来实现相同的功能。

Spring Runner

与 JUnit 4 一起使用的流行运行器之一是 Spring 测试运行器。使用 JUnit 5 时,我们必须用 Spring 扩展替换该运行器。

如果我们使用 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 存储库的额外依赖项。

要与 Spring 4 一起使用SpringExtension,我们必须在 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>

build.gradle同样的方法,我们在使用Gradle时也要添加依赖:

repositories {
    mavenCentral()
    maven { url 'https://jitpack.io' }
}

dependencies {
    testImplementation('com.github.sbrannen:spring-test-junit5:1.5.0')
}

Mockito Runner

JUnit 4 中使用的另一个流行运行器是 Mockito 运行器。使用 JUnit 5 时,我们需要用 Mockito JUnit 5 扩展替换此运行器。

为了使用 Mockito 扩展 mockito-junit-jupiter,我们必须在 Maven 中添加依赖项pom.xml

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

规则

@RuleJUnit 4 中的和注解@ClassRule在 JUnit 5 中不存在。我们可以通过使用包中的新扩展模型org.junit.jupiter.api.extension@ExtendWith注解来实现相同的功能。

但是,为了提供逐步的迁移路径,模块中支持 JUnit 4 规则子集及其子类junit-jupiter-migrationsupport

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

@EnableRuleMigrationSupport通过使用org.junit.jupiter.migrationsupport.rules包中的类级别注解,使用这些规则的现有代码可以保持不变。

为了在 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

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

如果我们有大量依赖规则的测试,则启用规则迁移支持可能是一个有效的渐进步骤。

但是,完全迁移到 JUnit 5 需要我们摆脱该规则,并用assertThrows()方法替换它:

class JUnit5ExpectedExceptionTest {

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

        assertEquals("Wrong argument!", thrown.getMessage());
    }
}

由于我们将所有内容都放在一个地方,因此结果更具可读性。

临时文件夹

在 JUnit 4 中,我们可以使用该TemporaryFolder规则来创建和清理临时文件夹。同样,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);
    }
}

为了彻底摆脱 JUnit 5 中的规则,我们必须用TempDirectory扩展来替换它。我们可以通过使用带有 @TempDir 注解的Path File字段来使用扩展:

class JUnit5TemporaryFolderTest {

    @TempDir
    Path temporaryDirectory;

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

该扩展与上一个规则非常相似。 一个区别是,我们也可以将注解添加到方法参数中:

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

自定义规则

迁移自定义 JUnit 4 规则需要将代码重写为 JUnit 5 扩展。

@Rule可以通过实现BeforeEachCallbackAfterEachCallback 接口来重现作为应用的规则逻辑。

例如,如果我们有一条执行性能日志记录的 JUnit 4 规则:

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

自定义类规则

分别地,我们可以通过@ClassRule实现BeforeAllCallbackAfterAllCallback接口来重现应用的规则逻辑。

在某些情况下,我们可能在 JUnit 4 中将类规则编写为内部匿名类。在以下示例中,我们有一个服务器资源,希望能够轻松设置以用于不同的测试:

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运行器。此外,我们还需要通过带有注解的方法来提供参数化数据@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这样的社区运行者将自己描述为不错的参数化测试

不幸的是,JUnit 4 参数化运行器没有直接的替代品。相反,在 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 参数化测试教程中阅读有关改进的更多信息。

概括

从 JUnit 4 迁移到 JUnit 5 需要做一些工作,具体取决于现有测试的编写方式。

  • 我们可以运行 JUnit 4 测试和 JUnit 5 测试,以实现逐步迁移。
  • 在很多情况下,我们只需要查找和替换包名和类名。
  • 我们可能必须将自定义运行器和规则转换为扩展。
  • 为了转换参数化测试,我们可能需要做一些返工。

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

原文链接:https://www.arhohuttunen.com/junit-5-migration/

Share this post:

Related content