什么是单元测试
单元测试就是一部分代码,但是它
- 不会在正常的业务流程中被执行
- 不被打包进入最终的编译程序
- 不会被任何其他业务代码以任何方式导入
- 不会影响正常的代码
当然,它通常还要满足下面这些条件
- 自动化的,不需要人工输入任何数据即可完成
- 独立的,任何两个单元测试之间都不应该发生调用关系
- 可重复的,单元测试可以无限重复执行且结果应该一致
传统的单元测试,即是测试一个函数是否正确运行。单元测试可以为这个函数预先伪造一个测试环境,例如用户登录了,且已经有超管权限了,那么运行这个函数是否能够得到我们期望得到的结果
注意上面这段文字中的提到的「为这个函数预先伪造一个测试环境」,这似乎不是很难理解,让我来举个例子:
事实上,很多时候 mock 并不是解决这个问题的。我们希望单元测试能够单独测试一个函数是否逻辑正确,那么我们仅需要测试这个函数即可,当这个函数需要调用其他函数的时候,我们会对函数进行 mock 使得得到我们期望的值。这样就可以实现仅仅校验此函数的逻辑是否正确了
单元测试的意义
- 在不需要启动服务的情况下,检查代码逻辑是否有问题
- 保证代码在后续的迭代过程中,被其他人更新后导致原来可以正常运行的结果变得不正确了
因为单元测试是负责完成代码测试的,所以当完整的单元测试写完之后,我们就可以通过单元测试来校验代码逻辑是否有问题
同时单元测试将会一直存在与源代码中,后续每一次需要进行校验发布时,都可以通过运行一次单元测试来检查是否因为本次修改,导致之前的逻辑出现错误
单元测试的标准
- 单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元 测试中不准使用 System.out 来进行人肉验证,必须使用 assert 来验证。
- 保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。
- 单元测试是可以重复执行的,不能受到外界环境的影响。
- 对于单元测试,要保证测试粒度足够小,有助于精确定位问题。单测粒度至多是类级别,一般是方法级别。
- 单元测试代码必须写在如下工程目录:src/test/java,不允许写在业务代码目录下。
- 单元测试应当包含「边界值测试」、「正确的输入」、「强制错误信息输入」的测试,而不是仅仅以满足覆盖率为标准
你需要会哪些代码知识
本博客的知识范围是 SpringBoot 框架,所以你必须要掌握下面的技能
- 能够熟练使用 Java 语言编写代码
- 了解 SpringBoot 的 AOP 的设计思想,会使用依赖注入
- 能够看懂上面的基本概念,了解单元测试的重要性
- 会使用 maven,并知道如何使用 maven
- 能够阅读中文,并能看懂本博客
开始写单元测试
单元测试的代码应该位于你的项目目录 src/test/java 下,接下来所有的内容目录都指此目录
导入 maven 依赖
我们需要了解下面几个重要的依赖,但是并非都是需要添加的,请继续阅读
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <!-- 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])
所以,实际上你只需要最后两个依赖即可完成本片博客的所有内容,但是还是有必要详细解释一下这些依赖在本博客中起到的作用
- junit5:必须的组件,提供了最重要的注解和单元测试能力
- mockito-core:必须的组件,提供了最重要的 Mock 的能力
- mockito-inline:非必须组件,提供了对静态方法的 Mock 能力,如果不需要对静态方法进行 Mock 则可以不需要
- spring-boot-starter-test:非必须组件,提供了对类的 private 变量的赋值能力,实际上反射也可以做到,但是通常为了方便期间,可以直接使用已经有的轮子
当你确定好需要的依赖之后,将其最新版本添加到你的 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 类用于测试对应的类。接下来我们需要介绍一些注解和类。
1 2 3 4 5 6 7
| @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 方法上,表示此方法是一个测试
|
添加注解
接下来,按照上面的描述,为你的每个测试类都添加这些需要的注解,我们可以得到类似下面的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @ExtendWith(MockitoExtension.class) class UserServiceImplTest { @BeforeEach void setUp() { // 这里的代码将会在每个测试前运行 } @AfterEach void tearDown() { // 这里的代码将会在每个测试结束后运行 } /** * 测试登录,用户不存在的情况 */ @Test void testLoginWithNoSuchUser() { // 这里编写你的测试代码 } }
|
我们已经完成了最基本的类的创建,虽然我们还没有开始调用登录的函数,但是我们已经完成类绝大部分的任务。
注入类
接下来,让我们将需要测试的类注入进来
在类中最开头添加类似下面的代码
1 2 3 4 5 6 7
| @InjectMocks private UserServiceImpl userService; // 需要测试的类,需要用 @InjectMocks 注解@Mock private UserDaoImpl userDao; // 需要 mock 的类,需要用 @Mock 注解
|
然后,开始测试
1 2 3 4 5 6 7
| @Test void testLoginWithNoSuchUser() { Boolean isSuccess = userService.login("handle", "password"); Assertions.assertFalse(isSuccess); // 校验返回值是否正确 }
|
但是这样肯定是不行的,因为你会发现,这样运行的结果会使得 isSuccess 为 null,而不是我们期望的结果。当然,我们也还没有配置 mock 的内容。
mock it!
接下来让我们开始 mock 吧,尝试类似下面的代码
1 2 3 4 5 6 7 8 9
| @Test void testLoginWithNoSuchUser() { // 表示「当调用 userDao#selectUserByHandle 且参数为 "handle" 时,则返回 null」 Mockito.when(userDao.selectUserByHandle("handle")).thenReturn(null); Boolean isSuccess = userService.login("handle", "password"); Assertions.assertFalse(isSuccess); // 校验返回值是否正确 }
|
再运行一次看看?是不是完美了?
回头看看我们做的过程,是否让单元测试变得更加简单了,编写单元测试仅需要三步
- 编写 Mockito 内容
- 调用函数
- 校验返回值或者参数
下面将会介绍几种常见的情况
应对各种情况
通用匹配类型
有时候我们并不喜欢指明参数必须要是什么,例如无论什么调用时,都返回 null,此时,参数可以使用 Mockito.any() 来表示任意参数,例如:
1
| Mockito.when(userDao.selectUserByHandle(Mockito.any())).thenReturn(null);
|
指定调用的目标函数的返回值
这已经在上面提及到了,也就是最常见的问题
让调用的目标函数抛出错误
将 thenReturn 改为 thenThrow 即可
让调用的目标函数做一些指定的事情
如果希望更加自定义函数的内容,譬如做点什么,则可以使用 thenAnswer 来解决
1 2 3 4 5 6 7
| Mockito.when(userDao.selectUserByHandle(Mockito.any())).thenAnswer(invocationOnMock -> { String handle = invocationOnMock.getArgument(0); // 获取第 0 个参数 if (handle == "handle") { return null; } return 1; })
|
如何应对没有返回值的函数
把 then 的部分向前提就行,并改为 do 系列
1 2 3 4 5
| Mockito.doAnswer(invocationOnMock -> { User argument = invocationOnMock.getArgument(0); argument.setId(1); return null; // 必须要返回些什么 }).when(userDao).insertAccount(Mockito.any());
|
如何控制那些静态的函数
假如我们有一个校验密码的静态方法 BCryptEncoder#encode,那么下面就是一个很好的例子
1 2 3 4 5 6 7
| MockedStatic<BCryptEncoder> bCryptEncoderMockedStatic; bCryptEncoderMockedStatic = Mockito.mockStatic(BCryptEncoder.class); bCryptEncoderMockedStatic.when(() -> BCryptEncoder.encoder("abc")).thenReturn("123");// do something bCryptEncoderMockedStatic.close();
|
如何测试 private 的方法
private 方法不应该被测试,因为其他类不会调用这方法。应该通过 public 间接测试 private 方法
如何校验函数的参数
我们以注册用户的时候使用的插入用户至数据库为例
1 2 3
| ArgumentCaptor<User> userArgumentCaptor = ArgumentCaptor.forClass(User.class); // 创建一个捕获类 Mockito.verify(userManager, Mockito.times(1)).insertAccount(userArgumentCaptor.capture()); // 第一次插入的时候,捕获参数 User userCP = userArgumentCaptor.getValue(); 获取被捕获的参数的值,后面就可以直接校验 userCP 了
|