程序员社区

单元测试框架 JUnit 进阶指南


title: 单元测试框架 JUnit 进阶指南
date: 2021/09/16 10:11


一、JUnit4

Runner

先问一个问题,你有没有想过,为什么在单元测试中被 @Test注解的方法会被执行?为什么@Before@After@BeforeClass@AfterClass注解会被解析并在指定的时机执行?为什么会记录测试用例的耗时?

这是因为 JUnit 用例全部都是通过 Runner(运行器)来执行的,所谓运行器的作用就是在单元测试执行过程中提供一些特定的功能,JUnit 默认使用 BlockJunit4ClassRunner 作为运行器,也就是他为我们解析的以上注解,并且在指定时机执行,通过监听器记录开始和结束时间最终计算出用例的耗时等。

通过 @RunWith注解即可指定测试用例所使用的 Runner。

常见的执行器:

Runner Description
BlockJunit4ClassRunner(基础) 解析 JUnit 提供的注解,封装单元测试类的运行过程
Suite 将一些 Runner 组合起来,一起执行
Parameterized 继承于 Suite,根据参数数组列表的个数创建多个基于该测试类的Runner
SpringRunner 解析 Spring 提供的注解,进行 DI

BlockJunit4ClassRunner

该执行器提供了对 JUnit4 提供的注解解析的功能,包括@Before@After@BeforeClass@AfterClass@Rule等。

首先将断点打到这里,往前看他的执行流程:

单元测试框架 JUnit 进阶指南插图

大体流程如下:

单元测试框架 JUnit 进阶指南插图1
image-20210825134526673

图来源:深入JUnit源码之Runner

SpringRunner

Spring 提供了一个 Runner SpringRunner,他继承了 BlockJunit4ClassRunner,所以具有它的所有功能,并且扩展了一些其他的功能,比如说:在单元测试方法执行前,解析类上的@Autowired注解进行 DI(当然需要启动 Spring 容器,容器中注入哪些 bean 则是通过 @SpringBootTest注解或@ContextConfiguration注解指定)。

单元测试框架 JUnit 进阶指南插图2

TestContextManager

由于市面上有多种单元测试框架,Spring 将他们共有的功能抽取成了 TestContextManager,提供了如下方法:

单元测试框架 JUnit 进阶指南插图3
image-20210916100423253

而红框中的那些功能又是委托给了 TestExecutionListener 来执行的。

TestExecutionListener

public interface TestExecutionListener {

    default void beforeTestClass(TestContext testContext) throws Exception {
    }

    default void prepareTestInstance(TestContext testContext) throws Exception {
    }

    default void beforeTestMethod(TestContext testContext) throws Exception {
    }

    default void beforeTestExecution(TestContext testContext) throws Exception {
    }

    default void afterTestExecution(TestContext testContext) throws Exception {
    }

    default void afterTestMethod(TestContext testContext) throws Exception {
    }
  
    default void afterTestClass(TestContext testContext) throws Exception {
    }
}

Spring 提供了如下 TestExecutionListener,为解析 Spring 提供的各类可以在单元测试中使用的注解。

单元测试框架 JUnit 进阶指南插图4

二、JUnit5

2.2.1 简介

JUnit 5 与以前版本的 JUnit 不同,拆分成由三个不同子项目的几个不同模块组成。

  • JUnit Platform:用于JVM上启动测试框架的基础服务,提供命令行,IDE和构建工具等方式执行测试的支持。
  • JUnit Jupiter:包含 JUnit 5 新的编程模型和扩展模型,主要就是用于编写测试代码和扩展代码。
  • JUnit Vintage:用于在JUnit 5 中兼容运行 JUnit3.x 和 JUnit4.x 的测试用例。
单元测试框架 JUnit 进阶指南插图5

2.2.2 新特性

  1. 提供全新的断言和测试注解,支持测试类内嵌

  2. 更丰富的测试方式:支持动态测试,重复测试,参数化测试等

  3. 实现了模块化,让测试执行和测试发现等不同模块解耦,减少依赖

  4. 提供对 Java 8 的支持,如 Lambda 表达式,Sream API等

2.2.3 常见用法介绍

接下来,我们看下 JUni 5 的一些常见用法,来帮助我们快速掌握 JUnit 5 的使用。

首先,在 Maven 工程里引入 JUnit 5 的依赖坐标,需注意的是当前JDK 环境要在 Java 8 以上。

<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter-engine</artifactId>
  <version>5.5.2</version>
  <scope>test</scope>
</dependency>
第一个测试用例

引入JUnit 5,我们可以先快速编写一个简单的测试用例,从这个测试用例来认识初步下 JUnit 5:

@DisplayName("我的第一个测试用例")
public class MyFirstTestCaseTest {

    @BeforeAll
    public static void init() {
        System.out.println("初始化数据");
    }

    @AfterAll
    public static void cleanup() {
        System.out.println("清理数据");
    }

    @BeforeEach
    public void tearup() {
        System.out.println("当前测试方法开始");
    }

    @AfterEach
    public void tearDown() {
        System.out.println("当前测试方法结束");
    }

    @DisplayName("我的第一个测试")
    @Test
    void testFirstTest() {
        System.out.println("我的第一个测试开始测试");
    }

    @DisplayName("我的第二个测试")
    @Test
    void testSecondTest() {
        System.out.println("我的第二个测试开始测试");
    }
}

直接运行这个测试用例,可以看到控制台日志如下:

单元测试框架 JUnit 进阶指南插图6

可以看到左边一栏的结果里显示测试项名称就是我们在测试类和方法上使用 @DisplayName 设置的名称,这个注解就是 JUnit 5 引入,用来定义一个测试类并指定用例在测试报告中的展示名称,这个注解可以使用在类上和方法上,在类上使用它就表示该类为测试类,在方法上使用则表示该方法为测试方法。

再来看下示例代码中使用到的一对注解 @BeforeAll@AfterAll ,它们定义了整个测试类在开始前以及结束时的操作,只能修饰静态方法,主要用于在测试过程中所需要的全局数据和外部资源的初始化和清理。与它们不同,@BeforeEach@AfterEach 所标注的方法会在每个测试用例方法开始前和结束时执行,主要是负责该测试用例所需要的运行环境的准备和销毁。

禁用执行测试:@Disabled

当我们希望在运行测试类时,跳过某个测试方法,正常运行其他测试用例时,我们就可以用上 @Disabled 注解,表明该测试方法处于不可用,执行测试类的测试方法时不会被 JUnit 执行。

下面看下使用 @Disbaled 之后的运行效果,在原来测试类中添加如下代码:

@DisplayName("我的第三个测试")
@Disabled
@Test
void testThirdTest() {
    System.out.println("我的第三个测试开始测试");
}

运行后看到控制台日志如下,用 @Disabled 标记的方法不会执行,只有单独的方法信息打印:

单元测试框架 JUnit 进阶指南插图7

@Disabled 也可以使用在类上,用于标记类下所有的测试方法不被执行,一般使用对多个测试类组合测试的时候。

内嵌测试类:@Nested

当我们编写的类和代码逐渐增多,随之而来的需要测试的对应测试类也会越来越多。为了解决测试类数量爆炸的问题,JUnit 5提供了@Nested 注解,能够以静态内部成员类的形式对测试用例类进行逻辑分组。并且每个静态内部类都可以有自己的生命周期方法, 这些方法将按从外到内层次顺序执行。此外,嵌套的类也可以用@DisplayName 标记,这样我们就可以使用正确的测试名称。下面看下简单的用法:

@DisplayName("内嵌测试类")
public class NestUnitTest {
    @BeforeEach
    void init() {
        System.out.println("测试方法执行前准备");
    }

    @Nested
    @DisplayName("第一个内嵌测试类")
    class FirstNestTest {
        @Test
        void test() {
            System.out.println("第一个内嵌测试类执行测试");
        }
    }

    @Nested
    @DisplayName("第二个内嵌测试类")
    class SecondNestTest {
        @Test
        void test() {
            System.out.println("第二个内嵌测试类执行测试");
        }
    }
}

运行所有测试用例后,在控制台能看到如下结果:

单元测试框架 JUnit 进阶指南插图8
重复性测试:@RepeatedTest

在 JUnit 5 里新增了对测试方法设置运行次数的支持,允许让测试方法进行重复运行。当要运行一个测试方法 N次时,可以使用 @RepeatedTest 标记它,如下面的代码所示:

@DisplayName("重复测试")
@RepeatedTest(value = 3)
public void i_am_a_repeated_test() {
    System.out.println("执行测试");
}

运行后测试方法会执行3次,在 IDEA 的运行效果如下图所示:

单元测试框架 JUnit 进阶指南插图9

这是基本的用法,我们还可以对重复运行的测试方法名称进行修改,利用 @RepeatedTest 提供的内置变量,以占位符方式在其 name 属性上使用,下面先看下使用方式和效果:

@DisplayName("自定义名称重复测试")
@RepeatedTest(value = 3, name = "{displayName} 第 {currentRepetition} 次")
public void i_am_a_repeated_test_2() {
    System.out.println("执行测试");
}
单元测试框架 JUnit 进阶指南插图10

@RepeatedTest 注解内用 currentRepetition 变量表示已经重复的次数,totalRepetitions 变量表示总共要重复的次数,displayName 变量表示测试方法显示名称,我们直接就可以使用这些内置的变量来重新定义测试方法重复运行时的名称。

新的断言

在断言 API 设计上,JUnit 5 进行显著地改进,并且充分利用 Java 8 的新特性,特别是 Lambda 表达式,最终提供了新的断言类: org.junit.jupiter.api.Assertions 。许多断言方法接受 Lambda 表达式参数,在断言消息使用 Lambda 表达式的一个优点就是它是延迟计算的,如果消息构造开销很大,这样做一定程度上可以节省时间和资源。

现在还可以将一个方法内的多个断言进行分组,使用 assertAll 方法如下示例代码:

@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 错误进行抛出提示。

超时操作的测试:assertTimeoutPreemptively

当我们希望测试耗时方法的执行时间,并不想让测试方法无限地等待时,就可以对测试方法进行超时测试,JUnit 5 对此推出了断言方法 assertTimeout,提供了对超时的广泛支持。

假设我们希望测试代码在一秒内执行完毕,可以写如下测试用例:

@Test
@DisplayName("超时方法测试")
void test_should_complete_in_one_second() {
  Assertions.assertTimeoutPreemptively(Duration.of(1, ChronoUnit.SECONDS), () -> Thread.sleep(2000));
}

这个测试运行失败,因为代码执行将休眠两秒钟,而我们期望测试用例在一秒钟之内成功。但是如果我们把休眠时间设置一秒钟,测试仍然会出现偶尔失败的情况,这是因为测试方法执行过程中除了目标代码还有额外的代码和指令执行会耗时,所以在超时限制上无法做到对时间参数的完全精确匹配。

异常测试:assertThrows

我们代码中对于带有异常的方法通常都是使用 try-catch 方式捕获处理,针对测试这样带有异常抛出的代码,而 JUnit 5 提供方法 Assertions#assertThrows(Class<T>, Executable) 来进行测试,第一个参数为异常类型,第二个为函数式接口参数,跟 Runnable 接口相似,不需要参数,也没有返回,并且支持 Lambda表达式方式使用,具体使用方式可参考下方代码:

@Test
@DisplayName("测试捕获的异常")
void assertThrowsException() {
  String str = null;
  Assertions.assertThrows(IllegalArgumentException.class, () -> {
    Integer.valueOf(str);
  });
}

当Lambda表达式中代码出现的异常会跟首个参数的异常类型进行比较,如果不属于同一类异常,就会控制台输出如下类似的提示:org.opentest4j.AssertionFailedError: Unexpected exception type thrown ==> expected: <IllegalArgumentException> but was: <...Exception>

2.2.4 扩展机制 @ExtendWith

扩展机制与 Runner 的功能类似,在单元测试执行的过程中实现一些功能。

单元测试框架 JUnit 进阶指南插图11
image-20210906164343268

在 SpringBoot2.0 中的@SpringBootTest注解就标注了 @ExtendWith({SpringExtension.class}),使单元测试伴随着 Spring 环境(不需要 @RunWith 注解)。

为什么 SpringBoot2.0 中的@SpringBootTest注解中标注了 @ExtendWith,但是 SpringBoot1.5 中没有标注 @RunWith 注解,还需要自己手动添加 @RunWith 注解?

我觉得可能是1.5 的时候想着要兼容所有单元测试库,而在 2 的时候选用 junit 作为默认,还不如直接就标注上去。

赞(0) 打赏
未经允许不得转载:IDEA激活码 » 单元测试框架 JUnit 进阶指南

一个分享Java & Python知识的社区