事出有因
在日常的开发中,很多人习惯性地写完需求代码后,嗖的一声用 Postman 模拟真实请求或写几个 JUnit 的单元测试跑功能点,只要没有问题就上线了,但其实这存在很大风险,一方面无法验证业务逻辑的不同分支,另外一方面需严重依赖中间件资源才能运行测试用例,占用大量资源。
秣马厉兵
Mockito是一个非常优秀的模拟框架,可以使用它简洁的API来编写漂亮的测试代码,它的测试代码可读性高同时会产生清晰的错误日志。
添加 maven 依赖
-
org.mockito -
mockito-core -
3.3.3 -
test -
注意:Mockito 3.X 版本使用了 JDK8 API,但功能与 2.X 版本并没有太大的变化。
指定 MockitoJUnitRunner
- @RunWith(MockitoJUnitRunner.class)
- public class MockitoDemoTest {
-
- //注入依赖的资源对象
- @Mock
- private MockitoTestService mockitoTestService;
- @Before
- public void before(){
- MockitoAnnotations.initMocks(this);
- }
- }
从代码中观察到,使用 @Mock 注解标识哪些对象需要被 Mock,同时在执行测试用例前初始化 MockitoAnnotations.initMocks(this) 告诉框架使 Mock 相关注解生效。
验证对象行为 Verify
- @Test
- public void testVerify(){
- //创建mock
- List mockedList = mock(List.class);
- mockedList.add("1");
- mockedList.clear();
- //验证list调用过add的操作行为
- verify(mockedList).add("1");
- //验证list调用过clear的操作行为
- verify(mockedList).clear();
- //使用内建anyInt()参数匹配器,并存根
- when(mockedList.get(anyInt())).thenReturn("element");
- System.out.println(mockedList.get(2)); //此处输出为element
- verify(mockedList).get(anyInt());
- }
存根 stubbing
stubbing 完全是模拟一个外部依赖、用来提供测试时所需要的数据。
- @Test
- public void testStub(){
- //可以mock具体的类,而不仅仅是接口
- LinkedList mockedList = mock(LinkedList.class);
- //存根(stubbing)
- when(mockedList.get(0)).thenReturn("first");
- when(mockedList.get(1)).thenThrow(new RuntimeException());
- //下面会打印 "first"
- System.out.println(mockedList.get(0));
- //下面会抛出运行时异常
- System.out.println(mockedList.get(1));
- //下面会打印"null" 因为get(999)没有存根(stub)
- System.out.println(mockedList.get(999));
- doThrow(new RuntimeException()).when(mockedList).clear();
- //下面会抛出 RuntimeException:
- mockedList.clear();
- }
- 存根(stub)可以覆盖,测试方法可以覆盖全局设置的通用存根。
- 一旦做了存根,无论这个方法被调用多少次,方法将总是返回存根的值。
存根的连续调用
- @Test
- public void testStub() {
- when(mock.someMethod("some arg"))
- .thenThrow(new RuntimeException())
- .thenReturn("foo");
- mock.someMethod("some arg"); //第一次调用:抛出运行时异常
- //第二次调用: 打印 "foo"
- System.out.println(mock.someMethod("some arg"));
- //任何连续调用: 还是打印 "foo" (最后的存根生效).
- System.out.println(mock.someMethod("some arg"));
- //可供选择的连续存根的更短版本:
- when(mock.someMethod("some arg")).thenReturn("one", "two", "three");
- when(mock.someMethod(anyString())).thenAnswer(new Answer() {
- Object answer(InvocationOnMock invocation) {
- Object[] args = invocation.getArguments();
- Object mock = invocation.getMock();
- return "called with arguments: " + args;
- }
- });
- // "called with arguments: foo
- System.out.println(mock.someMethod("foo"));
- }
在做方法存根时,可以指定不同时机需要提供的测试数据,例如第一次调用返回 xxx,第二次调用时抛出异常等。
参数匹配器
- @Test
- public void testArugument{
- //使用内建anyInt()参数匹配器
- when(mockedList.get(anyInt())).thenReturn("element");
- System.out.println(mockedList.get(999)); //打印 "element"
- //同样可以用参数匹配器做验证
- verify(mockedList).get(anyInt());
-
- //注意:如果使用参数匹配器,所有的参数都必须通过匹配器提供。
- verify(mock)
- .someMethod(anyInt(), anyString(), eq("third argument"));
- //上面是正确的 - eq(0也是参数匹配器),而下面的是错误的
- verify(mock)
- .someMethod(anyInt(), anyString(), "third argument");
- }
验证调用次数
- @Test
- public void testVerify{
- List
mockedList = new ArrayList(); - mockedList.add("once");
- mockedList.add("twice");
- mockedList.add("twice");
- mockedList.add("three times");
- mockedList.add("three times");
- mockedList.add("three times");
- //下面两个验证是等同的 - 默认使用times(1)
- verify(mockedList).add("once");
- verify(mockedList, times(1)).add("once");
- verify(mockedList, times(2)).add("twice");
- verify(mockedList, times(3)).add("three times");
- //使用using never()来验证. never()相当于 times(0)
- verify(mockedList, never()).add("never happened");
- //使用 atLeast()/atMost()来验证
- verify(mockedList, atLeastOnce()).add("three times");
- verify(mockedList, atLeast(2)).add("five times");
- verify(mockedList, atMost(5)).add("three times");
- }
验证调用顺序
- @Test
- public void testOrder()
- {
- // A. 单个Mock,方法必须以特定顺序调用
- List singleMock = mock(List.class);
-
- //使用单个Mock
- singleMock.add("was added first");
- singleMock.add("was added second");
-
- //为singleMock创建 inOrder 检验器
- InOrder inOrder = inOrder(singleMock);
-
- //确保add方法第一次调用是用"was added first",然后是用"was added second"
- inOrder.verify(singleMock).add("was added first");
- inOrder.verify(singleMock).add("was added second");
- }
以上是 Mockito 框架常用的使用方式,但 Mockito 有一定的局限性, 它只能 Mock 类或者接口,对于静态、私有及final方法的 Mock 则无能为力了。
而 PowerMock 正是弥补这块的缺陷,它的实现原理如下:
- 当某个测试方法被注解 @PrepareForTest 标注后,在运行测试用例时会创建一个新的 MockClassLoader 实例并加载该测试用例使用到的类(系统类除外)。
- PowerMock 会根据你的 mock 要求,去修改写在注解 @PrepareForTest 里的 class 文件内容(调用非系统的静态、Final方法),若是包含调用系统的方法则修改调用系统方法的类的 class 文件内容达到满足需求 。
但值得高兴的是在 Mockito2.7.2 及更高版本添加了对 final 类及方法支持[1] 。
同样, Mockito3.4.0 及更高版本支持对静态方法的 Mock[2],虽然是处于孵化阶段,但对于我们做单元测试而言是已经足够了。
决胜之机
大多数项目使用了 Spring 或 Spring Boot 作为基础框架,研发只需要关心业务逻辑即可。
在代码例子中将使用 Junit5 的版本,因此要求 Spring boot版本必须是2.2.0版本或以上,采用 Mockito3.5.11 的版本作为 Mock 框架,减少项目对 PowerMock 的依赖,另外还有一个重要原因是因为目前PowerMock不支持 Junit5,无法在引入 PowerMock 后使用Junit5 的相关功能及API,本文项目代码地址:https://github.com/GoQeng/spring-mockito3-demo。
maven 配置
-
1.8 -
3.5.11 -
1.10.15 -
3.13.4 -
5.1.48 -
0.8.6 -
5.6.2 -
1.1.1 -
2.1.3 -
3.8.1 -
2.12.4 -
1.4.197 -
-
- -- spring boot相关依赖 -->
-
-
org.springframework.boot -
spring-boot-starter-web -
-
-
-
org.springframework.boot -
spring-boot-starter-test -
test -
-
-
org.mockito -
mockito-core -
-
-
org.junit.vintage -
junit-vintage-engine -
-
-
-
- -- Mockito -->
-
-
org.mockito -
mockito-core -
${mockito.version} -
compile -
-
-
net.bytebuddy -
byte-buddy -
-
-
net.bytebuddy -
byte-buddy-agent -
-
-
- -- 由于mockito-core自带的byte-buddy版本低,无法使用mock静态方法 -->
-
-
net.bytebuddy -
byte-buddy -
${byte-buddy.version} -
-
-
-
net.bytebuddy -
byte-buddy-agent -
${byte-buddy.version} -
test -
-
-
-
org.mockito -
mockito-inline -
${mockito.version} -
test -
-
- -- mybatis -->
-
-
org.mybatis.spring.boot -
mybatis-spring-boot-starter -
${mybatis-spring.version} -
-
- -- redisson -->
-
-
org.redisson -
redisson-spring-boot-starter -
${redisson-spring.version} -
-
-
junit -
junit -
-
-
compile -
-
- -- mysql -->
-
-
mysql -
mysql-connector-java -
${mysql.version} -
-
- -- 代码覆盖率报表-->
-
-
org.jacoco -
jacoco-maven-plugin -
${jacoco.version} -
-
- -- junit5 -->
-
-
org.junit.jupiter -
junit-jupiter -
${junit-jupiter.version} -
test -
-
-
-
org.junit.platform -
junit-platform-runner -
${junit-platform.version} -
-
-
junit -
junit -
-
-
-
- -- H2数据库-->
-
-
com.h2database -
h2 -
${h2.version} -
test -
-
-
junit -
junit -
-
-
-
-
-
-
-
org.apache.maven.plugins -
maven-surefire-plugin -
${maven-surefire.version} -
- --指定在mvn的test阶段执行此插件 -->
-
-
test -
-
test -
-
-
-
-
once -
false -
-
**/SuiteTest.java -
-
-
-
-
org.apache.maven.plugins -
maven-compiler-plugin -
${maven-compiler.version} -
-
-
8 -
-
-
-
org.jacoco -
jacoco-maven-plugin -
${jacoco.version} -
-
-
-
prepare-agent -
-
- -- attached to Maven test phase -->
-
-
report -
test -
-
report -
-
-
-
-
-
-
-
-
org.jacoco -
jacoco-maven-plugin -
-
-
- -- select non-aggregate reports -->
-
report -
-
-
-
-
-
maven 运行测试用例是通过调用 maven 的 surefire 插件并 fork 一个子进程来执行用例的。
forkMode 属性指明是为每个测试创建一个进程还是所有测试共享同一个进程完成,forkMode 设置值有 never、once、always 、pertest 。
- pretest:每一个测试创建一个新进程,为每个测试创建新的JVM进程是单独测试的最彻底方式,但也是最慢的,不适合持续回归。
- once:在一个进程中进行所有测试。once 为默认设置,在持续回归时建议使用默认设置。
- always:在一个进程中并行的运行脚本,Junit4.7 以上版本才可以使用,surefire 的版本要在 2.6 以上提供这个功能,其中 threadCount 执行时,指定可分配的线程数量,只和参数 parallel 配合使用有效,默认为 5。
- never:从不创建新进程进行测试。
环境准备
在项目中 test 目录下建立测试入口类 TestApplication.java,将外部依赖 Redis 单独配置到 DependencyConfig.java 中,同时需要在 TestApplication.class 中排除对 Redis 或 Mongodb 的自动注入配置等。
注意:将外部依赖配置到DependencyConfig并不是必要的,此步骤的目的是为了避免每个单元测试类运行时都会重启 Spring 上下文,可采用 @MockBean 的方式在代码中引入外部依赖资源替代此方法。
- @Configuration
- public class DependencyConfig {
-
- @Bean
- public RedissonClient getRedisClient() {
- return Mockito.mock(RedissonClient.class);
- }
-
- @Bean
- public RestTemplate restTemplate() {
- return Mockito.mock(RestTemplate.class);
- }
- }
接着在测试入口类中通过 @ComponentScan 对主入口启动类 Application.class 及 RestClientConfig.class 进行排除。
- @SpringBootApplication
- @ComponentScan(excludeFilters = @ComponentScan.Filter(
- type = FilterType.ASSIGNABLE_TYPE,
- classes = {Application.class, RestClientConfig.class}))
- @MapperScan("com.example.mockito.demo.mapper")
- public class TestApplication {
-
- }
为了不单独写重复的代码,我们一般会把单独的代码抽取出来作为一个公共基类,其中 @ExtendWith(SpringExtension.class) 注解目的是告诉 Spring boot 将使用 Junit5 作为运行平台,如果想买中使用 Junit4 的话,则需要使用 @RunWith(SpringRunner.class) 注解告知用 SpringRunner 运行启动。
- @SpringBootTest(classes = TestApplication.class)@ExtendWith(SpringExtension.class)
- public abstract class SpringBaseTest {}
准备好配置环境后,我们便可以开始对项目的 Mapper、Service、Web 层进行测试了。
Mapper层测试
对 Mapper 层的测试主要是验证 SQL 语句及 Mybatis 传参等准确性。
- server:
- port: 8080
- spring:
- test:
- context:
- cache:
- max-size: 42
- main:
- allow-bean-definition-overriding: true
- datasource:
- url: jdbc:h2:mem:test;MODE=MYSQL;DB_CLOSE_DELAY=-1;INIT=runscript from 'classpath:init.sql'
- username: sa
- password:
- driverClassName: org.h2.Driver
- hikari:
- minimum-idle: 5
- maximum-pool-size: 15
- auto-commit: true
- idle-timeout: 30000
- pool-name: DatebookHikariCP
- max-lifetime: 1800000
- connection-timeout: 10000
- connection-test-query: SELECT 1
-
- mybatis:
- type-aliases-package: com.example.mockito.demo.domain
- mapper-locations:
- - classpath:mapperSuiteTest.java
-
-
-
生成覆盖率报告
-
org.jacoco -
jacoco-maven-plugin -
${jacoco.version} -
-
-
-
prepare-agent -
-
- -- 绑定到test阶段 -->
-
-
report -
test -
-
report -
-
-
-
项目中使用 jacoco 作为代码覆盖率工具,在命令行中运行 mvn clean test 后会执行所有单元测试用例,随后会在 target 目录下生成site 文件夹,文件夹包含 jacoco 插件生成的测试报告。
报告中主要包含本次测试中涉及到的类、方法、分支覆盖率,其中红色的表示未被覆盖到,绿色表示全覆盖,黄色的则表示部分覆盖到,可点击某个包或某个类查看具体哪些行未被覆盖等。
注意:
- test 测试目录与 main 开发目录的资源是相互隔离的。
- 使用 @MockBean 会导致启动 Spring 上下文多次,影响测试效率。
- 使用 @AutoConfigureMockMvc 注解导致 Spring 上下文多次重启,建议使用MockMvcBuilders.standaloneSetup(demoController).build() 构建。
言而总之
虽然编写单元测试会带来一定的工作量,但通过使用 Mockito 不仅可以保留测试用例,还可以快速验证改动后的代码逻辑,对复杂或依赖中间件多的项目而言,使用 Mockito 的优势会更加明显。
除此之外,更加重要的是我们自己可以创建条件来模拟各种情景下代码的逻辑准确性,保证代码的质量,提高代码维护性、提前发现潜在的问题,不再因为账号问题等导致测不到某些逻辑代码,使得项目上线也心里有底。
引用
[1]Mockito2.7.2 及更高版本添加了对 final 类及方法支持: https://www.baeldung.com/mockito-final
[2]Mockito3.4.0 及更高版本支持对静态方法的 Mock: https://tech.cognifide.com/blog/2020/mocking-static-methods-made-possible-in-mockito-3.4.0