仓库源文站点原文


title: 使用 Junit5 和 Mockito 实现 SpringBoot 的单元测试最优美的解决方案 date: 2022-04-22 17:17:27 updated: 2022-04-22 17:17:27 mermaid: true categories: 杂项 tags:


什么是单元测试

单元测试就是一部分代码,但是它

当然,它通常还要满足下面这些条件

传统的单元测试,即是测试一个函数是否正确运行。单元测试可以为这个函数预先伪造一个测试环境,例如用户登录了,且已经有超管权限了,那么运行这个函数是否能够得到我们期望得到的结果

注意上面这段文字中的提到的「为这个函数预先伪造一个测试环境」,这似乎不是很难理解,让我来举个例子:

事实上,很多时候 mock 并不是解决这个问题的。我们希望单元测试能够单独测试一个函数是否逻辑正确,那么我们仅需要测试这个函数即可,当这个函数需要调用其他函数的时候,我们会对函数进行 mock 使得得到我们期望的值。这样就可以实现仅仅校验此函数的逻辑是否正确了

单元测试的意义

因为单元测试是负责完成代码测试的,所以当完整的单元测试写完之后,我们就可以通过单元测试来校验代码逻辑是否有问题

同时单元测试将会一直存在与源代码中,后续每一次需要进行校验发布时,都可以通过运行一次单元测试来检查是否因为本次修改,导致之前的逻辑出现错误

单元测试的标准

你需要会哪些代码知识

本博客的知识范围是 SpringBoot 框架,所以你必须要掌握下面的技能

开始写单元测试

单元测试的代码应该位于你的项目目录 src/test/java 下,接下来所有的内容目录都指此目录

导入 maven 依赖

我们需要了解下面几个重要的依赖,但是并非都是需要添加的,请继续阅读

<!-- Junit 5 -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
</dependency>
<!-- Mockito 核心 -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
</dependency>
<!-- Mockito 对 static 支持 -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-inline</artifactId>
</dependency>
<!-- Spring 对单元测试支持 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
</dependency>

以上这些依赖的相互依赖关系如下

graph LR
c([mockito-inline]) ----> b([mockito-core])
d([spring-boot-starter-test]) ----> b([mockito-core])
d([spring-boot-starter-test]) ----> a([junit-jupiter])

所以,实际上你只需要最后两个依赖即可完成本片博客的所有内容,但是还是有必要详细解释一下这些依赖在本博客中起到的作用

当你确定好需要的依赖之后,将其最新版本添加到你的 maven 里吧

创建测试类

首先,需要进行逻辑测试的永远是某个实现类,而不是接口,因为接口并不是需要测试的,我们需要测试的是实现的过程是否有问题

创建类用来编写你的单元测试。通常我们会根据需要测试的类进行单独建测试类,即每一个类对应一个测试类,每一个测试类,仅测试对应类的方法。例如,我们有 src/main/java/com/example/service/impl/UserServiceImpl.java 类,那么我们创建 src/test/java/com/example/service/impl/UserServiceImplTest.java 用于测试 UserServiceImpl 类。

例如,我们创建了 src/test/java/com/example/service/impl/UserServiceImplTest.java 类用于测试对应的类。接下来我们需要介绍一些注解和类。

@ExtendWith() // 来自 junit 5 的注解,用于测试类上,表示此测试需要额外使用什么扩展工具
MockitoExtension // 来自 Mockito-core 的类,是 Mockito 的扩展工具,用于 junit 5 使用,junit 4 并不是这个
@BeforeAll // 来自 junit5 的注解,用于 static 方法上,表示在进行此类的所有测试方法前,执行一次此函数,仅一次
@AfterAll // 来自 junit 5 的注解,与 @BeforeAll 类似,但是表示所有测试方法结束后执行一次,仅一次
@BeforeEach // 来自 junit 5 的注解,用于非 static 方法上,表示在此类的所有测试方法将被执行前,每个都执行一次
@AfterEach // 来自 junit 5 的注解,与 @BeforeEach 类似,但是是在每个测试方法结束后,都执行一次
@Test // 来自 junit 5 的注解,用于非 static 方法上,表示此方法是一个测试

添加注解

接下来,按照上面的描述,为你的每个测试类都添加这些需要的注解,我们可以得到类似下面的代码

@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {

    @BeforeEach
    void setUp() {
        // 这里的代码将会在每个测试前运行
    }

    @AfterEach
    void tearDown() {
        // 这里的代码将会在每个测试结束后运行
    }

    /**
     * 测试登录,用户不存在的情况
     */
    @Test
    void testLoginWithNoSuchUser() {
        // 这里编写你的测试代码
    }
}

我们已经完成了最基本的类的创建,虽然我们还没有开始调用登录的函数,但是我们已经完成类绝大部分的任务。

注入类

接下来,让我们将需要测试的类注入进来

在类中最开头添加类似下面的代码


    @InjectMocks
    private UserServiceImpl userService; // 需要测试的类,需要用 @InjectMocks 注解

    @Mock
    private UserDaoImpl userDao; // 需要 mock 的类,需要用 @Mock 注解

然后,开始测试


    @Test
    void testLoginWithNoSuchUser() {
        Boolean isSuccess = userService.login("handle", "password");
        Assertions.assertFalse(isSuccess); // 校验返回值是否正确
    }

但是这样肯定是不行的,因为你会发现,这样运行的结果会使得 isSuccessnull,而不是我们期望的结果。当然,我们也还没有配置 mock 的内容。

mock it!

接下来让我们开始 mock 吧,尝试类似下面的代码


    @Test
    void testLoginWithNoSuchUser() {
        // 表示「当调用 userDao#selectUserByHandle 且参数为 "handle" 时,则返回 null」
        Mockito.when(userDao.selectUserByHandle("handle")).thenReturn(null);
        Boolean isSuccess = userService.login("handle", "password");
        Assertions.assertFalse(isSuccess); // 校验返回值是否正确
    }

再运行一次看看?是不是完美了?

回头看看我们做的过程,是否让单元测试变得更加简单了,编写单元测试仅需要三步

下面将会介绍几种常见的情况

应对各种情况

通用匹配类型

有时候我们并不喜欢指明参数必须要是什么,例如无论什么调用时,都返回 null,此时,参数可以使用 Mockito.any() 来表示任意参数,例如:

Mockito.when(userDao.selectUserByHandle(Mockito.any())).thenReturn(null);

指定调用的目标函数的返回值

这已经在上面提及到了,也就是最常见的问题

让调用的目标函数抛出错误

thenReturn 改为 thenThrow 即可

让调用的目标函数做一些指定的事情

如果希望更加自定义函数的内容,譬如做点什么,则可以使用 thenAnswer 来解决

Mockito.when(userDao.selectUserByHandle(Mockito.any())).thenAnswer(invocationOnMock -> {
    String handle = invocationOnMock.getArgument(0); // 获取第 0 个参数
    if (handle == "handle") {
        return null;
    }
    return 1;
})

如何应对没有返回值的函数

把 then 的部分向前提就行,并改为 do 系列

Mockito.doAnswer(invocationOnMock -> {
    User argument = invocationOnMock.getArgument(0);
    argument.setId(1);
    return null; // 必须要返回些什么
}).when(userDao).insertAccount(Mockito.any());

如何控制那些静态的函数

假如我们有一个校验密码的静态方法 BCryptEncoder#encode,那么下面就是一个很好的例子

MockedStatic<BCryptEncoder> bCryptEncoderMockedStatic;
bCryptEncoderMockedStatic = Mockito.mockStatic(BCryptEncoder.class);
bCryptEncoderMockedStatic.when(() -> BCryptEncoder.encoder("abc")).thenReturn("123");

// do something

bCryptEncoderMockedStatic.close();

如何测试 private 的方法

private 方法不应该被测试,因为其他类不会调用这方法。应该通过 public 间接测试 private 方法

如何校验函数的参数

我们以注册用户的时候使用的插入用户至数据库为例

ArgumentCaptor<User> userArgumentCaptor = ArgumentCaptor.forClass(User.class); // 创建一个捕获类
Mockito.verify(userManager, Mockito.times(1)).insertAccount(userArgumentCaptor.capture()); // 第一次插入的时候,捕获参数
User userCP = userArgumentCaptor.getValue(); 获取被捕获的参数的值,后面就可以直接校验 userCP 了