JUint是Java编程语言的单元测试框架,用于编写和运行可重复的自动化测试。本文主要针对Junit5要点进行梳理总结。
一、浅谈单元测试
1.1 什么是单元测试
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义,如C语言中单元指一个函数,Java里单元指一个类,图形化的软件中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。
1.2 为什么要写单元测试
这其实是问单元测试能带来什么好处,之所以把这个问题放在这里讨论,是因为需要清楚单元测试的使用场景,以及它做得到和做不到的。我们在写好一个函数或者类以后,需要检验我们的程序是否存在bug或者是否满足我们的需求,通常的做法就是将写好的函数在mian方法中调用,输入一些测试用例进行检验。当要检验的方法数量较少时,这种方法可行,但是当我们有大量的函数需要验证时,该方法就显得笨重且繁琐,往往需要我们人工检查输出的结果是否正确等,比较混乱。因此,单元测试就是用来解决这种繁琐问题的。使用单元测试可以有效地降低程序出错的机率,提供准确的文档,并帮助我们改进设计方案等等。以下列举了一些我为什么使用单元测试的好处:
- 允许你对代码做出较任何改变,因为你了解单元测试会在你的预期之中。
- 单元测试可以有效地降低程序出现BUG的机率。
- 帮助你更深入地理解代码,因为在写单元测试的时候,你需要明确程序所有的执行流程及对应的执行结果等等。
- 允许在任何时候代码重构,而不必担心破坏现有的代码,这使得我们编写程序更灵活。
- 确保你的代码的健壮性,因为所有的测试都是通过了的。
1.3 什么时候写单元测试
写单元测试的时机不外乎三种情况:
- 一是在具体实现代码之前,这是测试驱动开发(TDD)所提倡的;
- 二是与具体实现代码同步进行。先写少量功能代码,紧接着写单元测试(重复这两个过程,直到完成功能代码开发)。其实这种方案跟第一种已经很接近,基本上功能代码开发完,单元测试也差不多完成了。
- 三是编写完功能代码再写单元测试。我的实践经验告诉我,事后编写的单元测试“粒度”都比较粗。对同样的功能代码,采取前两种方案的结果可能是用10个“小”的单测来覆盖,每个单测比较简单易懂,可读性可维护性都比较好(重构时单测的改动不大);而第三种方案写的单测,往往是用1个“大”的单测来覆盖,这个单测逻辑就比较复杂,因为它要测的东西很多,可读性可维护性就比较差。
个人比较推荐单元测试与具体实现代码同步进行这个方案的,只有对需求有一定的理解后才能知道什么是代码的正确性,才能写出有效的单元测试来验证正确性,而能写出一些功能代码则说明对需求有一定理解了。
二、初识 Junit5
2.1 什么是JUnit
JUint是Java编程语言的单元测试框架,用于编写和运行可重复的自动化测试。JUnit 5是JUnit的下一代,其目标是为JVM上的开发人员端测试创建一个最新的基础。这包括专注于Java 8及更高版本,以及启用许多不同风格的测试。
2.2 Junit5 架构
与以前版本的JUnit不同,JUnit 5由JUnit Platform、JUnit Jupiter、JUnit Vintage等三个不同子项目中的几个不同模块组成,如下所示:
其中,JUnit Platform是基于JVM的运行测试的基础框架;JUnit Jupiter是在JUnit 5中编写测试编程和扩展模型的组合,主要就是用于编写测试代码和扩展代码。而JUnit Vintage提供了一个TestEngine,用来兼容运行 JUnit3.x 和 JUnit4.x 的测试用例。
注意:Unit 5在运行时需要 Java 8 + 。当然,仍然可以测试使用以前版本的JDK编译的代码。
2.3 常用注解
JUnit Jupiter 是在JUnit 5中编写测试和扩展的新编程模型和扩展模型的组合,所以我们使用Jupiter来学习Junit5,这里列出一些常用的注解,如下表所示:
注解 | 说明 |
---|---|
@Test | 表示方法是一种测试方法。 与JUnit 4的@Test注解不同,此注释不会声明任何属性 |
@ParameterizedTest | 表示方法是参数化测试 |
@RepeatedTest | 表示方法是重复测试模板 |
@TestFactory | 表示方法是动态测试的测试工程 |
@DisplayName | 为测试类或者测试方法自定义一个名称 |
@BeforeEach | 表示方法在每个测试方法运行前都会运行 |
@AfterEach | 表示方法在每个测试方法运行之后都会运行 |
@BeforeAll | 表示方法在所有测试方法之前运行 |
@AfterAll | 表示方法在所有测试方法之后运行 |
@Nested | 表示带注解的类是嵌套的非静态测试类,@BeforeAll和 @AfterAll 不能直接在@Nested测试类中使用 |
@Tag | 用于在类或方法级别声明用于过滤测试的标记 |
@Disabled | 用于禁用测试类或测试方法 |
@ExtendWith | 用于注册自定义扩展,该注解可以继承 |
@FixMethodOrder | 测试类中方法执行的顺序 |
三、编写单元测试
3.1 引入相关依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>MavenDemo</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.9.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
3.2 编写测试类
JUnit 中最基本的注解非 @Test 莫属了,它会标记方法为测试方法,以便构建工具和 IDE 能够识别并执行它们。它的 API 和作用并没有变化,不过它不再接受任何参数了。若要测试是否抛出异常,可以通过新的断言 API 来做到。与 JUnit 4一样,JUnit 5 会为每个测试方法创建一个新的实例。示例代码如下所示:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class HelloWorldTest {
@Test
void firstTest() {
assertEquals(2, 1 + 1);
}
}
@Test注解在方法上标记方法为测试方法,以便构建工具和 IDE 能够识别并执行它们。JUnit 5不再需要手动将测试类与测试方法为public,包可见的访问级别就足够了。
3.3 测试实例生命周期
首先,需要对比下Junit5和Junit4注解:
Junit4 | Junit5 | 注释 |
---|---|---|
@Test | @Test | 表示该方法是一个测试方法 |
@BeforeClass | @BeforeAll | 表示使用了该注解的方法应该在当前类中所有测试方法之前执行(只执行一次),并且它必须是 static方法( |
@AfterClass | @AfterAll | 表示使用了该注解的方法应该在当前类中所有测试方法之后执行(只执行一次),并且它必须是 static方法 |
@Before | @BeforeEach | 表示使用了该注解的方法应该在当前类中每一个测试方法之前执行 |
@After | @AfterEach | 表示使用了该注解的方法应该在当前类中每一个测试方法之后执行 |
@Ignore | @Disabled | 用于禁用(或者说忽略)一个测试类或测试方法 |
引入JUnit 5,我们可以先快速编写一个简单的测试用例,从这个测试用例来认识JUnit 5:
import org.junit.jupiter.api.*;
@DisplayName("我的第一个测试用例")
public class StandardTest {
@BeforeAll
static void initAll() {
System.out.println("@BeforeAll 初始化数据");
}
@BeforeEach
void init() {
System.out.println("@BeforeEach 当前测试方法开始");
}
@AfterEach
void tearDown() {
System.out.println("@AfterEach 当前测试方法结束");
}
@AfterAll
static void tearDownAll() {
System.out.println("@AfterEach 清理数据");
}
@DisplayName("我的第一个测试")
@Test
void testFirstTest() {
System.out.println("我的第一个测试开始测试");
}
@DisplayName("我的第二个测试")
@Test
void testSecondTest() {
System.out.println("我的第二个测试开始测试");
}
}
直接运行这个测试用例,可以看到控制台日志如下:
从上图可以看到左边一栏的结果里显示测试项名称就是我们在测试类和方法上使用 @DisplayName 设置的名称,这个注解就是 JUnit 5 引入,用来定义一个测试类并指定用例在测试报告中的展示名称,这个注解可以使用在类上和方法上,在类上使用它就表示该类为测试类,在方法上使用则表示该方法为测试方法。
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@API(status = Status.STABLE,since = "5.0")
public @interface DisplayName {
String value();
}
3.4 禁用测试
当我们希望在运行测试类时,跳过某个测试方法,正常运行其他测试用例时,我们就可以用上 @Disabled 注解,表明该测试方法处于不可用,执行测试类的测试方法时不会被 JUnit 执行。下面看下使用 @Disbaled 之后的运行效果,在原来测试类中添加如下代码:
@Disabled
@Test
@DisplayName("我的第三个测试")
void testThirdTest() {
System.out.println("我的第三个测试开始测试");
}
运行后看到控制台日志如下,用 @Disabled 标记的方法不会执行,只有单独的方法信息打印:
@Disabled 也可以使用在类上,用于标记类下所有的测试方法不被执行,一般使用对多个测试类组合测试的时候。
3.5 重复测试
JUnit Jupiter 通过使用 @RepeatedTest 注解方法并指定所需的重复次数,提供了重复测试指定次数的功能。每次重复测试的调用都像执行常规的@Test方法一样,完全支持相同的生命周期回调和扩展。以下示例演示了如何声明名为repeatedTest()的测试,该测试将自动重复3次。
import org.junit.jupiter.api.*;
public class RepeatedUnitTest {
@DisplayName("重复测试")
@RepeatedTest(value = 3)
public void repeatedTest() {
System.out.println("执行测试");
System.out.println();
}
}
运行后测试方法会执行3次,在 IDEA 的运行效果如下图所示:
除了指定重复次数外,还可以通过@RepeatedTest注解的name属性为每次重复配置自定义显示名称。此外,显示名称可以是模式,由静态文本和动态占位符的组合而成。目前支持以下占位符:
占位符 | 说明 |
---|---|
{displayName} | 测试方法显示名称 |
{currentRepetition} | 当前重复次数 |
{totalRepetitions} | 重复的总次数 |
import org.junit.jupiter.api.*;
public class RepeatedUnitTest {
@DisplayName("自定义名称重复测试")
@RepeatedTest(value = 3, name = "{displayName} 第 {currentRepetition} 次")
public void repeatedTest() {
System.out.println("执行测试");
}
}
运行后看到控制台日志如下:
3.6 断言测试
准备好测试实例、执行了被测类的方法以后,断言能确保你得到了想要的结果。一般的断言,无非是检查一个实例的属性(判空与判非空等),又或者对两个实例进行比较等。无论哪种检查,断言方法都可以接受一个字符串作为最后一个可选参数,它会在断言失败时提供必要的描述信息。在断言 API 设计上,JUnit 5 进行显著地改进,并且充分利用 Java 8 的新特性,特别是 Lambda 表达式,最终提供了新的断言类: org.junit.jupiter.api.Assertions 。许多断言方法接受 Lambda 表达式参数,在断言消息使用 Lambda 表达式的一个优点就是它是延迟计算的,如果消息构造开销很大,这样做一定程度上可以节省时间和资源。断言测试注解如下表所示:
断言 | 描述 |
---|---|
assertEquals | 断言预期值和实际值相等 |
assertAll | 分组断言,执行其中包含的所有断言 |
assertArrayEquals | 断言预期数组和实际数组相等 |
assertFalse | 断言条件为假 |
assertNotNull | 断言不为空 |
assertSame | 断言两个对象相等 |
assertTimeout | 断言超时 |
fail | 使单元测试失败 |
现在还可以将一个方法内的多个断言进行分组,使用 assertAll 方法如下示例代码:
import org.junit.jupiter.api.*;
@DisplayName("断言测试")
public class AssertionTest {
@Test
void testGroupAssertions() {
int[] numbers = {0, 1, 2, 3, 4};
Assertions.assertAll("numbers",
() -> Assertions.assertEquals(numbers[1], 1),
() -> Assertions.assertEquals(numbers[3], 3),
() -> Assertions.assertEquals(numbers[4], 4)
);
}
}
如果分组断言中任一个断言的失败,都会将以 MultipleFailuresError 错误进行抛出提示。
3.7 参数化测试
参数化测试可以用不同的参数多次运行测试。它们和普通的@Test
方法一样声明,但是使用@ParameterizedTest
注解。要使用 JUnit 5 进行参数化测试,除了 junit-jupiter-engine 基础依赖之外,还需要另外模块依赖:junit-jupiter-params,其主要就是提供了编写参数化测试 API。同样方式,把相同版本的对应依赖引入 Maven 工程中:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
public class ParameterizedUnitTest {
@ParameterizedTest
@ValueSource(ints = {2, 4, 8})
void testNumberShouldBeEven(int num) {
Assertions.assertEquals(0, num % 2);
}
@ParameterizedTest
@ValueSource(strings = {"Effective Java", "Code Complete", "Clean Code"})
void testPrintTitle(String title) {
System.out.println(title);
}
}
运行测试,结果如下图所示:
其中,@ValueSource 是 JUnit 5 提供的最简单的数据参数源,支持 Java 的八大基本类型和字符串、Class,使用时赋值给注解上对应类型属性,以数组方式传递。@ParameterizedTest 作为参数化测试的必要注解,替代了 @Test 注解,任何一个参数化测试方法都需要标记上该注解。如上面示例所示,针对 @ValueSource 里每个参数都会运行目标方法,一旦哪个参数运行测试失败,就意味着该测试方法不通过。
除了上面提到的**@ValueSource** 外,JUnit 5 还提供了一个使用Enum
常量的方法,即 @EnumSource,该注释提供了可选的name
参数,可以指定使用哪些常量;可选的mode
参数,可以对将哪些常量传递给测试方法进行细化控制。如下例所示。
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.params.provider.EnumSource.Mode.EXCLUDE;
public class ParameterizedUnitTest {
@ParameterizedTest
@EnumSource(TimeUnit.class)
@DisplayName("@EnumSource 默认")
void testWithEnumSource(TimeUnit timeUnit) {
}
@ParameterizedTest
@DisplayName("@EnumSource 指定使用哪些常量")
@EnumSource(value = TimeUnit.class, names = {"DAYS", "HOURS"})
void testWithEnumSourceInclude(TimeUnit timeUnit) {
}
@ParameterizedTest
@DisplayName("@EnumSource 从枚举常量池中排除")
@EnumSource(value = TimeUnit.class, mode = EXCLUDE, names = {"DAYS", "HOURS"})
void testWithEnumSourceExclude(TimeUnit timeUnit) {
}
}
运行测试,结果如下图所示:
除了上面提到的**@ValueSource、@EnumSource外,JUnit 5 还提供了允许引用一个或多个测试类的工厂方法的@MethodSource**。使用 @MethodSource 的方法必须返回一个Stream、Iterable、Iterator或者参数数组。另外,这种方法不能接受任何参数。如果只需要一个参数,则可以返回参数类型的实例Stream
,如以下示例所示。
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
public class ParameterizedUnitTest {
@ParameterizedTest
@MethodSource("stringProvider")
void testWithSimpleMethodSource(String argument) {
}
static Stream<String> stringProvider() {
return Stream.of("foo", "bar");
}
}
四、结语
到这里,想必你对 JUnit 5 也有了基本的了解和掌握,都说单元测试是提升软件质量,提升研发效率的必备环节,从会用 JUnit 5 写单元测试开始,培养写测试代码的习惯,在不断实践中提升自身的开发效率,让写出来的代码有更质量的保证。