对于所有的 Java 开发人员来说,你可以没有听说过 Spring 或是
Hibernate 框架,但是一定听说过 JUnit。JUnit 作为 Java 单元测试的鼻祖与事实上的标准,在非常多的项目中被使用。即便新兴的单元测试框架,如
TestNG 等,不断出现,JUnit 的重要性仍然是不言而喻的。目前广泛使用的是 JUnit 4 版本,而
JUnit 即将迎来它的最新版本 JUnit 5。JUnit 5 在增加了很多的新特性的同时,又保持了对
JUnit 4 的向后兼容性。本文对 JUnit 5 进行了详细的介绍。
JUnit 5 简介
与之前的版本不同,JUnit 5 由三个不同的模块组成。第一个模块是 JUnit 平台,其主要作用是在
JVM 上启动测试框架。它定义了一个抽象的 TestEngine API 来定义运行在平台上的测试框架,同时还支持通过命令行、Gradle
和 Maven 来运行平台。第二个模块是 JUnit Jupiter,包含了 JUnit 5 最新的编程模型和扩展机制。第三个模块是
JUnit Vintage,允许在平台上运行 JUnit 3 和 JUnit 4 的测试用例。
JUnit 5 对 Java 运行环境的最低要求是 Java 8。可以在 Eclipse 和 IntelliJ
IDEA 上运行 JUnit 5 测试。本文的示例基于 IntelliJ IDEA 上开发,并使用 Gradle
作为构建工具。不过目前 IDE 对 JUnit 5 的支持还比较有限,只有最新版本的 IntelliJ
IDEA 原生支持,在其它 IDE 上需要使用命令行工具来运行。
编写测试用例
JUnit 5 对编写单元测试用例的方式做了一系列的改进,如下介绍。
JUnit 5 注解
JUnit 5 提供了一些常用的注解在编写测试用例的时候使用。其中的一些注解和 JUnit 4 的注解有相同的名称,不过所在的
Java 包变成了 org.junit.jupiter.api。常用的注解见表 1。
表 1. JUnit 5 常用注解
清单 1 中给出了使用这些注解编写的单元测试用例。
清单 1. 使用常用注解的单元测试用例
@DisplayName("Calculator") public class CalculatorTest { private Calculator calculator; @BeforeAll public static void init() { System.out.println("Start testing"); } @BeforeEach public void create() { this.calculator = new Calculator(); } @AfterEach public void destroy() { this.calculator = null; } @AfterAll public static void cleanup() { System.out.println("Finish testing"); } @Test @DisplayName("Test 1 + 2 = 3") public void testAdd() { assertEquals(3, this.calculator.add(1, 2)); } @Test @DisplayName("Test 3 - 2 = 1") public void testSubtract() { assertEquals(1, this.calculator.subtract(3, 2)); } @Disabled @Test @DisplayName("disabled test") public void ignoredTest() { System.out.println("This test is disabled"); } } |
在这些注解中,最实用的应该是@DisplayName。通过@DisplayName,开发人员可以为每个测试用例添加更具体的名字,更容易传达用例所要测试的内容。
通过@Tag 注解可以为测试类或方法添加标签,但是不同的标签只是通过字符串来进行区分,并不是类型安全的。一个拼写错误就可能造成标签没有被正确应用。更好的做法是使用类型安全的元注解(meta
annotation)。编译器会对元注解标签的正确性进行验证,从而减少无意的错误。清单 2 中定义了元注解标签@Remote,对应于标签
remote。
清单 2. 元注解标签
@Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @Tag("remote") public @interface Remote { } |
清单 3 中展示了@Remote 的用法。使用@Remote 的作用等同于@Tag("Remote"),为
testGetUser 方法添加了标签 remote。使用@Remote 不仅提高了代码的可读性,也可以避免无意的拼写错误带来的问题。
清单 3. 使用元注解标签
@DisplayName("Remote test") public class RemoteTest { @Test @Remote public void testGetUser() { System.out.println("Get user"); } } |
JUnit 5 断言
断言(assertions)是测试方法中的核心部分,用来对测试需要满足的条件进行验证。这些断言方法都是
org.junit.jupiter.api.Assertions 的静态方法。JUnit 5 内置的断言可以分成如下几个类别:
第一类是简单断言,用来对单个值进行简单的验证,常用的方法见表 2。
表 2. 常用的断言方法
这些方法都有多个重载方法,可以提供额外的消息来作为断言不满足时的提示消息,还可以接受 Java 8 中的
Supplier 接口来获取要判断的值和显示的消息。清单 4 中给出了简单断言的使用示例。
清单 4. 简单断言
@Test @DisplayName("simple assertion") public void simple() { assertEquals(3, 1 + 2, "simple math"); assertNotEquals(3, 1 + 1); assertNotSame(new Object(), new Object()); Object obj = new Object(); assertSame(obj, obj); assertFalse(1 > 2); assertTrue(1 < 2); assertNull(null); assertNotNull(new Object()); } |
第二类是通过 assertArrayEquals 方法来判断两个对象或原始类型的数组是否相等,如清单 5
所示。
清单 5. assertArrayEquals 方法的示例
@Test @DisplayName("array assertion") public void array() { assertArrayEquals(new int[]{1, 2}, new int[] {1, 2}); } |
第三类是通过 assertAll 方法来判断一组断言是否满足。assertAll 方法接受多个 org.junit.jupiter.api.Executable
函数式接口的实例作为要验证的断言,可以通过 lambda 表达式很容易的提供这些断言,如清单 6 所示。
清单 6. assertAll 方法的示例
@Test @DisplayName("assert all") public void all() { assertAll("Math", () -> assertEquals(2, 1 + 1), () -> assertTrue(1 > 0) ); } |
第四类是通过 assertThrows 或 expectThrows 来判断是否抛出期望的异常类型。两个方法的参数都是所期望的异常类型和对应的
Executable 接口的实现对象,区别在于 expectThrows 方法会返回抛出的异常对象。在清单
7 中,1/0 会抛出 ArithmeticException 异常,assertThrows 用来验证这一点。
清单 7. assertThrows 和 expectThrows 方法的示例
@Test @DisplayName("throws exception") public void exception() { assertThrows(ArithmeticException.class, () -> System.out.println(1 / 0)); } |
第五类是 fail 方法,用来使一个测试方法失败。清单 8 中的测试会直接失败。
清单 8. 通过 fail 方法直接使得测试失败
@Test @DisplayName("fail") public void shouldFail() { fail("This should fail"); } |
JUnit 5 前置条件
JUnit 5 中的前置条件(assumptions)类似于断言,不同之处在于不满足的断言会使得测试方法失败,而不满足的前置条件只会使得测试方法的执行终止。前置条件可以看成是测试方法执行的前提,当该前提不满足时,就没有继续执行的必要。在清单
9 中,assumeTrue 和 assumFalse 确保给定的条件为 true 或 false,不满足条件会使得测试执行终止。assumingThat
的参数是表示条件的布尔值和对应的 Executable 接口的实现对象。只有条件满足时,Executable
对象才会被执行;当条件不满足时,测试执行并不会终止。
清单 9. JUnit 5 前置条件
@DisplayName("Assumptions") public class AssumptionsTest { private final String environment = "DEV"; @Test @DisplayName("simple") public void simpleAssume() { assumeTrue(Objects.equals(this.environment, "DEV")); assumeFalse(() -> Objects.equals(this.environment, "PROD")); } @Test @DisplayName("assume then do") public void assumeThenDo() { assumingThat( Objects.equals(this.environment, "DEV"), () -> System.out.println("In DEV") ); } } |
嵌套测试
JUnit 5 可以通过 Java 中的内部类和@Nested 注解实现嵌套测试,从而可以更好的把相关的测试方法组织在一起。在内部类中可以使用@BeforeEach
和@AfterEach 注解,而且嵌套的层次没有限制。清单 10 中给出了使用嵌套测试的示例,用来测试
HashMap 的功能。
清单 10. 嵌套测试
@DisplayName("Nested tests for HashMap") public class MapNestedTest { Map<String, Object> map; @Nested @DisplayName("when a new") class WhenNew { @BeforeEach void create() { map = new HashMap<>(); } @Test @DisplayName("is empty") void isEmpty() { assertTrue(map.isEmpty()); } @Nested @DisplayName("after adding a new entry") class AfterAdd { String key = "key"; Object value = "value"; @BeforeEach void add() { map.put(key, value); } @Test @DisplayName("is not empty") void isNotEmpty() { assertFalse(map.isEmpty()); } @Test @DisplayName("returns value when getting by key") void returnValueWhenGettingByKey() { assertEquals(value, map.get(key)); } @Nested @DisplayName("after removing the entry") class AfterRemove { @BeforeEach void remove() { map.remove(key); } @Test @DisplayName("is empty now") void isEmpty() { assertTrue(map.isEmpty()); } @Test @DisplayName("returns null when getting by key") void returnNullForKey() { assertNull(map.get(key)); } } } } } |
依赖注入
在 JUnit 5 之前,标准的测试类和测试方法是不允许有额外的参数的。这个限制在 JUnit 5 被取消了。JUnit
5 除了提供内置的标准参数之外,还可以通过扩展机制来支持额外的参数。
当参数的类型是 org.junit.jupiter.api.TestInfo 时,JUnit 5 会在运行测试时提供一个
TestInfo 接口的对象。通过 TestInfo 接口,可以获取到当前测试的相关信息,包括显示名称、标签、测试类和测试方法,如清单
11 所示。
清单 11. TestInfo 依赖注入
@Test @DisplayName("test info") public void testInfo(final TestInfo testInfo) { System.out.println(testInfo.getDisplayName()); } |
当参数的类型是 org.junit.jupiter.api.TestReporter 时,在运行测试时,通过作为参数传入的
TestReporter 接口对象,来输出额外的键值对信息。这些信息可以被测试执行的监听器 TestExecutionListener
处理,也可以被输出到测试结果报告中,如清单 12 所示。
清单 12. TestReporter 依赖注入
@Test @DisplayName("test reporter") public void testReporter(final TestReporter testReporter) { testReporter.publishEntry("name", "Alex"); } |
除了 TestInfo 和 TestReporter 之外,也可以通过 JUnit 5 的扩展机制来添加对其他类型参数的支持。将在下面关于
JUnit 5 扩展机制的一节中进行介绍。
动态测试
目前所介绍的 JUnit 5 测试方法的创建都是静态的,在编译时刻就已经存在。JUnit 5 新增了对动态测试的支持,可以在运行时动态创建测试并执行。通过动态测试,可以满足一些静态测试无法解的需求,也可以完成一些重复性很高的测试。比如,有些测试用例可能依赖运行时的变量,有时候会需要生成上百个不同的测试用例。这些场景都是动态测试可以发挥其长处的地方。动态测试是通过新的@TestFactory
注解来实现的。测试类中的方法可以添加@TestFactory 注解的方法来声明其是创建动态测试的工厂方法。这样的工厂方法需要返回
org.junit.jupiter.api.DynamicTest 类的集合,可以是 Stream、Collection、Iterable
或 Iterator 对象。每个表示动态测试的 DynamicTest 对象由显示名称和对应的 Executable
接口的实现对象来组成。清单 13 中展示了@TestFactory 的示例。
清单 13. 动态测试
@TestFactory public Collection<DynamicTest> simpleDynamicTest() { return Collections.singleton(dynamicTest ("simple dynamic test", () -> assertTrue(2 > 1))); } |
DynamicTest 提供了一个静态方法 stream 来根据输入生成动态测试,如清单 14 所示。
清单 14. 通过 stream 方法来生成动态测试
@TestFactory public Stream<DynamicTest> streamDynamicTest() { return stream( Stream.of("Hello", "World").iterator(), (word) -> String.format("Test - %s", word), (word) -> assertTrue(word.length() > 4) ); } |
执行测试用例
JUnit 5 提供了三种不同的方式来执行测试用例,分别是通过 Gradle 插件、Maven 插件和命令行来运行。
Gradle
JUnit 5 提供了 Gradle 插件,在 Gradle 项目中运行单元测试,如清单 15 所示。
清单 15. 使用 JUnit 5 的 Gradle 插件
buildscript { repositories { mavenCentral() } dependencies { classpath 'org.junit.platform:junit-platform-gradle-plugin:1.0.0-M2' } } apply plugin: 'org.junit.platform.gradle.plugin' |
在启用了 Gradle 插件之后,可以通过 junitPlatformTest 任务来运行单元测试。可以在
Gradle 脚本中对插件进行定制,如通过 reportsDir 设置测试结果报告的生成路径,通过 tags
来设置包含或排除的标签名称,如清单 16 所示。
清单 16. 配置 JUnit 5 的 Gradle 插件
junitPlatform { platformVersion 1.0 reportsDir "build/test-results/junit-platform" tags { include 'fast', 'smoke' } } |
Maven
在 Maven 项目中可以通过 Surefire 插件来运行 JUnit 5 测试,只需要在 POM 文件中进行配置即可。如清单
17 所示。
清单 17. 在 Maven 项目中使用 JUnit 5
<build> <plugins> <plugin> <artifactId>maven-surefire-plugin</artifactId> <version>2.19</version> <dependencies> <dependency> <groupId>org.junit.platform</groupId> <artifactId>junit-platform-surefire-provider</artifactId> <version>1.0.0-M2</version> </dependency> </dependencies> </plugin> </plugins> </build> |
命令行
除了 Gradle 和 Maven 之外,还可以通过命令行来运行 JUnit 5 测试。只需要直接运行
Java 类 org.junit.platform.console.ConsoleLauncher 即可。ConsoleLauncher
提供了不同的命令行参数来配置测试运行的行为,如-n 来指定包含的 Java 类名满足的模式,-t 来包含标签,-T
来排除标签。
扩展机制
JUnit 5 提供了标准的扩展机制来允许开发人员对 JUnit 5 的功能进行增强。JUnit 5 提供了很多的标准扩展接口,第三方可以直接实现这些接口来提供自定义的行为。通过@ExtendWith
注解可以声明在测试方法和类的执行中启用相应的扩展。
扩展的启用是继承的,这既包括测试类本身的层次结构,也包括测试类中的测试方法。也就是说,测试类会继承其父类中的扩展,测试方法会继承其所在类中的扩展。除此之外,在一个测试上下文中,每一个扩展只能出现一次。
创建扩展
JUnit 5 中的扩展非常容易创建,只是实现了特定接口的 Java 类。JUnit 5 的扩展都需要实现
org.junit.jupiter.api.extension.Extension 接口,不过该接口只是一个标记接口,并没有任何需要实现的具体方法。真正起作用的是
Extension 的子接口,作为 JUnit 5 提供的扩展点。
测试执行条件
ContainerExecutionCondition 和 TestExecutionCondition
接口用来配置是否启用测试类或测试方法。前面提到的@Disabled 注解也是通过这样的机制来实现的。ContainerExecutionCondition
接口对应的是测试类,而 TestExecutionCondition 接口对应的是测试方法。
ContainerExecutionCondition 接口的 evaluate 方法接受 ContainerExtensionContext
接口作为参数,并返回 ConditionEvaluationResult 类的对象作为结果。通过 ContainerExtensionContext
接口可以获取到当前测试类的上下文信息,而 ConditionEvaluationResult 类则表示该测试类是否被启用。
TestExecutionCondition 接口也是包含一个 evaluate 方法,只不过参数类型是
TestExtensionContext,其返回结果也是 ConditionEvaluationResult
类的对象。
通过扩展的方式禁用的测试类和方法,可以通过 JVM 参数 junit.conditions.deactivate
来重新启用,只需要把相应的条件类禁用即可。
清单 18 中扩展 DisableAPITests 实现了 ContainerExecutionCondition
和 TestExecutionCondition 接口,当测试类或方法中包含标签 api 时,通过 ConditionEvaluationResult.disabled()表示对其禁用。
清单 18. 测试执行条件扩展示例
public class DisableAPITests implements ContainerExecutionCondition, TestExecutionCondition { @Override public ConditionEvaluationResult evaluate (final ContainerExtensionContext context) { return checkTags(context.getTags()); } @Override public ConditionEvaluationResult evaluate (final TestExtensionContext context) { return checkTags(context.getTags()); } private ConditionEvaluationResult checkTags (final Set<String> tags) { if (tags.contains("api")) { return ConditionEvaluationResult.disabled("No API tests!"); } return ConditionEvaluationResult.enabled(""); } } |
清单 19 中的测试类的 simpleAPITest 方法使用了标签 api,在执行时会被禁用。
清单 19. 使用 DisableAPITests 的测试用例
@ExtendWith(DisableAPITests.class) public class APITests { @Test @Tag("api") public void simpleAPITest() { System.out.println("simple API test"); } } |
后处理测试实例
通过 TestInstancePostProcessor 可以对测试实例添加后处理的逻辑,从而进一步对实例进行定制,比如可以通过依赖注入的方式来设置其中的属性,或是添加额外的初始化逻辑等。
在清单 20 中,扩展 InjectAPIEnv 实现了 TestInstancePostProcessor
接口,在 postProcessTestInstance 方法中通过 Commons Lang 中的 MethodUtils.invokeMethod
来调用当前测试实例中的 setEnv 方法,并设置为 DEV。
清单 20. 后处理测试实例的示例
public class InjectAPIEnv implements TestInstancePostProcessor { @Override public void postProcessTestInstance (final Object testInstance, final ExtensionContext context) throws Exception { MethodUtils.invokeMethod(testInstance, "setEnv", "DEV"); } } |
清单 21 中给出了使用该扩展的示例。
清单 21. 使用后处理测试实例的示例
@ExtendWith(InjectAPIEnv.class) public class APITests { private String env; public void setEnv(final String env) { this.env = env; } @Test public void showInjected() { assertEquals("DEV", this.env); } } |
参数解析
在之前介绍 JUnit 5 的参数解析时,提到了 JUnit 5 可以自动解析 TestInfo 和 TestReporter
类型的参数。除了这两种类型的参数之外,也可以通过扩展 ParameterResolver 接口来提供自定义的参数解析功能。ParameterResolver
接口中有两个方法,分别是 supports 和 resolve。两个方法的参数是一样的,分别是 ParameterContext
和 ExtensionContext 接口的对象。通过 ParameterContext 可以获取到需要解析的参数的信息,而
ExtensionContext 接口可以获取到当前测试类或方法的上下文信息。
清单 22. 参数解析的示例
public class APIEnvResolver implements ParameterResolver { @Override public boolean supports (final ParameterContext parameterContext, final ExtensionContext extensionContext) throws ParameterResolutionException { return parameterContext.getParameter().getType() == String.class && parameterContext.getIndex() == 0; } @Override public Object resolve(final ParameterContext parameterContext, ? final ExtensionContext extensionContext) throws ParameterResolutionException { return "DEV"; } } |
清单 23 给出了使用参数解析扩展的示例。
清单 23. 使用参数解析扩展的示例
@ExtendWith(APIEnvResolver.class) public class APITests { @Test public void showResolved(final String env) { assertEquals("DEV", env); } } |
测试执行回调方法
JUnit 5 提供了一系列与测试执行过程相关的回调方法,在测试执行中的不同阶段,运行自定义的逻辑。这些回调方法可以用来做一些与日志和性能分析的任务。具体的回调方法和描述见表
3。
表 3. 测试执行中的回调方法
清单 24 中给出了使用测试执行中的回调方法的示例。
清单 24. 使用测试执行回调方法的示例
public class Timing implements BeforeTestExecutionCallback, AfterTestExecutionCallback { @Override public void beforeTestExecution (final TestExtensionContext context) throws Exception { getStore(context).put (context.getTestMethod().get(), System.currentTimeMillis()); } @Override public void afterTestExecution (final TestExtensionContext context) throws Exception { final Method testMethod = context.getTestMethod().get(); final long start = getStore(context).remove(testMethod, long.class); final long duration = System.currentTimeMillis() - start; context.publishReportEntry(ImmutableMap.of (testMethod.getName(), Long.toString(duration))); } private Store getStore(TestExtensionContext context) { return context.getStore(Namespace.create(getClass(), context)); } } |
异常处理
通过 TestExecutionExceptionHandler 接口可以对测试运行中抛出的异常进行处理。可以在运行中忽略某些异常,或是在特定类型的异常发生时执行某些处理动作,如可以在出现数据库异常时回滚事务。清单
25 给出了异常处理的示例。
清单 25. 异常处理的示例
public class IgnoreNullPointerException implements TestExecutionExceptionHandler { @Override public void handleTestExecutionException (final TestExtensionContext context, final Throwable throwable) throws Throwable { if (throwable instanceof NullPointerException) { return; } throw throwable; } } |
迁移指南
JUnit 平台可以通过 Jupiter 引擎来运行 JUnit 5 测试,Vintage 引擎来运行
JUnit 3 和 JUnit 4 测试。因此,已有的 JUnit 3 和 4 的测试不需要任何修改就可以直接在
JUnit 平台上运行。只需要确保 Vintage 引擎的 jar 包出现在 classpath 中,JUnit
平台会自动发现并使用该引擎来运行 JUnit 3 和 4 测试。开发人员可以按照自己的项目安排来规划迁移到
JUnit 5 的进度。可以保持已有的 JUnit 3 和 4 的测试用例不变,而新增加的测试用例则使用
JUnit 5。
在进行迁移的时候需要注意如下的变化:
注解在 org.junit.jupiter.api 包中,断言在 org.junit.jupiter.api.Assertions
类中,前置条件在 org.junit.jupiter.api.Assumptions 类中。
把@Before 和@After 替换成@BeforeEach 和@AfterEach。
把@BeforeClass 和@AfterClass 替换成@BeforeAll 和@AfterAll。
把@Ignore 替换成@Disabled。
把@Category 替换成@Tag。
把@RunWith、@Rule 和@ClassRule 替换成@ExtendWith。
小结
单元测试是应用程序不可或缺的一部分。作为 Java 开发中单元测试的事实标准,JUnit 被广泛使用。本文详细介绍了在
JUnit 5 中编写和运行测试用例的方式,并对新的扩展机制做了详细介绍。在编写测试用例方面,本文介绍了
JUnit 5 中新的注解、断言和前置条件,以及对于嵌套测试、依赖注入和动态测试的支持。在运行测试用例方面,详细介绍了通过
Gradle、Maven 和命令行来运行 JUnit 5 测试。扩展机制作为 JUnit 5 的一大亮点,本文详细介绍了如何通过扩展来添加测试执行条件、后处理测试实例、解析测试和处理异常等。开发人员可以现在就尝试
JUnit 5 中的新功能。 |