本文翻译自Migrating From JUnit 4 to JUnit 5: A Definitive Guide

JUnit 4 to JUnit 5

在教程中,我们将了解从JUnit 4迁移到JUnit 5所需的步骤,我们将了解如何将现有测试与新版本一起运行,以及必须进行哪些更改才能迁移代码。

本文是JUnit 5 教程的一部分。

概览

JUnit 5不同于之前的版本,它采用模块化设计,这种新架构的关键点是将编写测试、扩展和工具之间的关注点分开。

JUnit被拆分为3个不同的子工程:

  • JUnit Platform是基础,提供构建插件和用于编写测试引擎的API
  • JUnit Jupiter是在JUnit 5中用于编写测试和扩展的新式API
  • JUnit Vintage允许我们在JUnit 5中运行JUnit 4编写的测试

以下是JUnit 5相对于JUnit 4的一些优点:

JUnit 4最大的一个缺陷是不支持运行多个Runners(例如不能同时使用SpringJUnit4ClassRunnerParameterized),而在JUnit 5中可通过注册多个扩展实现。

此外,JUnit 5利用了Java 8中的一些特性如lambda用于惰性评估,JUnit 4从没使用超过Java 7的版本因而错失了Java 8的特性。

同样,JUnit 4在参数化测试方面有缺陷并且缺乏嵌套测试,由此激发了第三方开发者为这些场景开发专门的Runner。

JUnit 5给参数化测试提供了更好的支持,在对嵌套测试提供原生支持的同时也增加了一些新功能。

关键迁移步骤

JUnitJUnit Vintage测试引擎的帮助下提供了渐进的迁移路径,我们可使用JUnit Vintage测试引擎在JUnit 5中运行JUnit 4相关的测试。

所有JUnit 4相关的类都位于org.junit包下,所有JUnit 5相关的类都位于org.junit.jupiter包下,如果JUnit 4JUnit 5在当前类路径下同时存在,也不会产生冲突。

因此,我们可以将之前实现的JUnit 4测试与JUnit 5测试保留在一起,直到完成迁移,同时可逐步规划迁移。

下属表格总结了从JUnit 4迁移到JUnit 5中的一些关键步骤:

步骤 解释
替换依赖 JUnit 4使用单个依赖,JUnit 5对迁移支持和JUnit Vintage引擎有额外的依赖项
替换注解 JUnit 5中的一些注解与JUnit 4中的相同,一些新注解替换了旧的,并且功能略有不同。
替换测试类和方法 断言和假设已被移动到新的类中,方法参数的顺序在某些场景下所有不同。
替换runners、规则、扩展 JUnit 5用一个扩展模型替换了runners和规则,此步骤相较于其它步骤更耗时。

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

依赖

先看看需要做什么才能在新平台上运行现有测试,为了同时运行JUnit 4JUnit 5测试,需要如下操作:

  • JUnit Jupiter用于编写和运行JUnit 5测试
  • JUnit Vintage测试引擎用于运行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注解不再具有expectedtimeout属性。

异常

我们无法在@Test注解中使用expected属性。

可用JUnit 5中的assertThrows()方法来替换JUnit 4中的expected属性:

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 5中使用assertTimeout()方法来替换JUnit 4中的timeout属性。

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 4JUnit 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移除了上述限制,测试方法和测试类可以在包中私有,我们可在提供的示例中看到这种差异。

接下来我们详细看看测试类和测试方法中的变化。

断言

断言方法位于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那样提供Assert.assertThat(),相反,我们必须从HamcrestMatcherAssert中导入相关方法:

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注解替换,此外我们不再使用标记接口,而是向注解传递字符串参数。

JUnit 4可通过标记接口来使用分类:

public interface IntegrationTest {}

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

之后可通过在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中使用的一个流行的runner是Spring test runner,在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仓库中的额外依赖。

要在JUnit 4中使用SpringExtension注解我们需要在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时需要将其替换为JUnit 5中的Mockito扩展。

要使用Mockito扩展,需要在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();
    }
}

规则

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

然而,要实现一个平滑的迁移,junit-jupiter-migrationsupport模块提供了JUnit 4中的规则子集和子类的支持:

  • ExternalResource(例如TemporaryFolder)
  • Verifier(例如ErrorCollector)
  • ExpectedException

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

要在Maven中开启该支持需要添加相关依赖:

<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 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 4中的规则,我们需要将其替换为TempDirectory扩展,可通过给一个PathFile属性添加@TempDir注解来使用此扩展:

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中的扩展。

可通过实现BeforeEachCallbackAfterEachCallback接口来重现引用了@Rule注解的业务逻辑。

例如,我们有一个实现性能日志的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
    }
}

自定义类规则

类似的,可通过实现BeforeEachCallbackAfterEachCallback接口来重现引用了@ClassRule注解的业务逻辑。

在某些场景中,我们可能在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 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参数化测试中阅读有关改进的更多信息。

总结

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

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

本文的示例代码能在GitHub中找到。