本文转载自微信公众号「码农私房话」,外部作者Liew 。依赖转载本文请联系码农私房话公众号。太多 在日常的何写开发中,很多人习惯性地写完需求代码后,单元嗖的测试一声用 Postman 模拟真实请求或写几个 JUnit 的单元测试跑功能点,只要没有问题就上线了,外部但其实这存在很大风险,依赖一方面无法验证业务逻辑的太多不同分支,另外一方面需严重依赖中间件资源才能运行测试用例,何写占用大量资源。单元 Mockito是测试一个非常优秀的模拟框架,可以使用它简洁的外部API来编写漂亮的测试代码,它的依赖测试代码可读性高同时会产生清晰的错误日志。 注意:Mockito 3.X 版本使用了 JDK8 API,太多但功能与 2.X 版本并没有太大的变化。 从代码中观察到,使用 @Mock 注解标识哪些对象需要被 Mock,同时在执行测试用例前初始化 MockitoAnnotations.initMocks(this) 告诉框架使 Mock 相关注解生效。 stubbing 完全是模拟一个外部依赖、用来提供测试时所需要的数据。 在做方法存根时,可以指定不同时机需要提供的测试数据,例如第一次调用返回 xxx,第二次调用时抛出异常等。 以上是 Mockito 框架常用的使用方式,但 Mockito 有一定的局限性, 它只能 Mock 类或者接口,对于静态、私有及final方法的 Mock 则无能为力了。 而 PowerMock 正是弥补这块的缺陷,它的实现原理如下: 但值得高兴的是在 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 运行测试用例是通过调用 maven 的 surefire 插件并 fork 一个子进程来执行用例的。 forkMode 属性指明是为每个测试创建一个进程还是所有测试共享同一个进程完成,forkMode 设置值有 never、once、always 、pertest 。 在项目中 test 目录下建立测试入口类 TestApplication.java,将外部依赖 Redis 单独配置到 DependencyConfig.java 中,同时需要在 TestApplication.class 中排除对 Redis 或 Mongodb 的自动注入配置等。 注意:将外部依赖配置到DependencyConfig并不是必要的,此步骤的目的是为了避免每个单元测试类运行时都会重启 Spring 上下文,可采用 @MockBean 的方式在代码中引入外部依赖资源替代此方法。 接着在测试入口类中通过 @ComponentScan 对主入口启动类 Application.class 及 RestClientConfig.class 进行排除。 为了不单独写重复的代码,我们一般会把单独的代码抽取出来作为一个公共基类,其中 @ExtendWith(SpringExtension.class) 注解目的是告诉 Spring boot 将使用 Junit5 作为运行平台,如果想买中使用 Junit4 的话,则需要使用 @RunWith(SpringRunner.class) 注解告知用 SpringRunner 运行启动。 准备好配置环境后,我们便可以开始对项目的 Mapper、Service、Web 层进行测试了。 对 Mapper 层的测试主要是验证 SQL 语句及 Mybatis 传参等准确性。 对 Mapper 层的测试并没有采取 Mock 的方式,而是采用 H2 内存数据库的方式模拟真实数据库,同时也避免由于测试数据给真实数据库带来的影响。 配置 H2 数据库信息,同时 INIT 指定在创建连接时会执行类路径下的 init.sql 即建表 SQL 。 一般项目的业务逻辑写在 service 层,需要写更多的测试用例验证业务代码逻辑性及准确性,尽可能的覆盖到业务代码的分支逻辑。 当写完 mapper、service、web 层的单元测试后,我们便可以把这些单元测试类都打包到套件中,并在 maven surefire 插件指定需要执行的套件类,我们可以使用 @SelectPackages 或 @SelectClass 注解把要测试的类包含进来。 把测试套件配置到surefire插件中。 项目中使用 jacoco 作为代码覆盖率工具,在命令行中运行 mvn clean test 后会执行所有单元测试用例,随后会在 target 目录下生成site 文件夹,文件夹包含 jacoco 插件生成的测试报告。 报告中主要包含本次测试中涉及到的类、方法、分支覆盖率,其中红色的表示未被覆盖到,绿色表示全覆盖,黄色的则表示部分覆盖到,可点击某个包或某个类查看具体哪些行未被覆盖等。 注意: 虽然编写单元测试会带来一定的工作量,但通过使用 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事出有因
秣马厉兵
添加 maven 依赖
<dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>3.3.3</version> <scope>test</scope> </dependency> 指定 MockitoJUnitRunner
@RunWith(MockitoJUnitRunner.class) public class MockitoDemoTest { //注入依赖的资源对象 @Mock private MockitoTestService mockitoTestService; @Before public void before(){ MockitoAnnotations.initMocks(this); } } 验证对象行为 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()参数匹配器,云南idc服务商并存根 when(mockedList.get(anyInt())).thenReturn("element"); System.out.println(mockedList.get(2)); //此处输出为element verify(mockedList).get(anyInt()); } 存根 stubbing
存根的连续调用
@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")); } 参数匹配器
@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<String> 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"); } 决胜之机
maven 配置
<properties> <java.version>1.8</java.version> <mockito.version>3.5.11</mockito.version> <byte-buddy.version>1.10.15</byte-buddy.version> <redisson-spring.version>3.13.4</redisson-spring.version> <mysql.version>5.1.48</mysql.version> <jacoco.version>0.8.6</jacoco.version> <junit-jupiter.version>5.6.2</junit-jupiter.version> <junit-platform.version>1.1.1</junit-platform.version> <mybatis-spring.version>2.1.3</mybatis-spring.version> <maven-compiler.version>3.8.1</maven-compiler.version> <maven-surefire.version>2.12.4</maven-surefire.version> <h2.version>1.4.197</h2.version> </properties> <dependencies> <!-- spring boot相关依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> </exclusion> <exclusion> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> </exclusion> </exclusions> </dependency> <!-- Mockito --> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>${ mockito.version}</version> <scope>compile</scope> <exclusions> <exclusion> <groupId>net.bytebuddy</groupId> <artifactId>byte-buddy</artifactId> </exclusion> <exclusion> <groupId>net.bytebuddy</groupId> <artifactId>byte-buddy-agent</artifactId> </exclusion> </exclusions> </dependency> <!-- 由于mockito-core自带的byte-buddy版本低,无法使用mock静态方法 --> <dependency> <groupId>net.bytebuddy</groupId> <artifactId>byte-buddy</artifactId> <version>${ byte-buddy.version}</version> </dependency> <dependency> <groupId>net.bytebuddy</groupId> <artifactId>byte-buddy-agent</artifactId> <version>${ byte-buddy.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-inline</artifactId> <version>${ mockito.version}</version> <scope>test</scope> </dependency> <!-- mybatis --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>${ mybatis-spring.version}</version> </dependency> <!-- redisson --> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>${ redisson-spring.version}</version> <exclusions> <exclusion> <groupId>junit</groupId> <artifactId>junit</artifactId> </exclusion> </exclusions> <scope>compile</scope> </dependency> <!-- mysql --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>${ mysql.version}</version> </dependency> <!-- 代码覆盖率报表--> <dependency> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>${ jacoco.version}</version> </dependency> <!-- junit5 --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>${ junit-jupiter.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.platform</groupId> <artifactId>junit-platform-runner</artifactId> <version>${ junit-platform.version}</version> <exclusions> <exclusion> <groupId>junit</groupId> <artifactId>junit</artifactId> </exclusion> </exclusions> </dependency> <!-- H2数据库--> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <version>${ h2.version}</version> <scope>test</scope> <exclusions> <exclusion> <groupId>junit</groupId> <artifactId>junit</artifactId> </exclusion> </exclusions> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>${ maven-surefire.version}</version> <executions> <!--指定在mvn的test阶段执行此插件 --> <execution> <id>test</id> <goals> <goal>test</goal> </goals> </execution> </executions> <configuration> <forkMode>once</forkMode> <skip>false</skip> <includes> <include>**/SuiteTest.java</include> </includes> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>${ maven-compiler.version}</version> <configuration> <source>8</source> <target>8</target> </configuration> </plugin> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>${ jacoco.version}</version> <executions> <execution> <goals> <goal>prepare-agent</goal> </goals> </execution> <!-- attached to Maven test phase --> <execution> <id>report</id> <phase>test</phase> <goals> <goal>report</goal> </goals> </execution> </executions> </plugin> </plugins> </build> <reporting> <plugins> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <reportSets> <reportSet> <reports> <!-- select non-aggregate reports --> <report>report</report> </reports> </reportSet> </reportSets> </plugin> </plugins> </reporting> 环境准备
Mapper层测试
Service层测试
WEB层测试
public class DemoControllerTest extends SpringBaseTest { private MockMvc mockMvc; @Mock private DemoService demoService; //把demoService注入到demoController中 @InjectMocks private DemoController demoController; @BeforeEach public void setUp() { MockitoAnnotations.openMocks(this); //手动构建,不使用@AutoConfigureMockMvc注解避免重复启动spring上下文 this.mockMvc = MockMvcBuilders.standaloneSetup(demoController).build(); } @Test public void addDemoTest() throws Exception { String name = "mock demo add"; mockMvc.perform(MockMvcRequestBuilders.post("/demo") .param("name", name)) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.content().string(containsString("OK"))) .andDo(MockMvcResultHandlers.print()); //验证调用次数,发生过一次 Mockito.verify(demoService, Mockito.times(1)).addDemo(ArgumentMatchers.any()); } @Test public void getDemoListTest() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/demos")) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.content().string("[]")) .andDo(MockMvcResultHandlers.print()); //验证调用次数,发生过一次 Mockito.verify(demoService, Mockito.times(1)).getList(); } @Test public void getDemoDetailTest() throws Exception { String name = "mock demo getDetail"; mockMvc.perform(MockMvcRequestBuilders.get("/demo/{ id}", 1)) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.content().string(containsString(""))) .andDo(MockMvcResultHandlers.print()); //验证调用次数,发生过一次 Mockito.verify(demoService, Mockito.times(1)).getDetail(ArgumentMatchers.anyInt()); } } 套件测试
生成覆盖率报告
<plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>${ jacoco.version}</version> <executions> <execution> <goals> <goal>prepare-agent</goal> </goals> </execution> <!-- 绑定到test阶段 --> <execution> <id>report</id> <phase>test</phase> <goals> <goal>report</goal> </goals> </execution> </executions> </plugin> 言而总之