mock测试及jacoco覆盖率
单元测试是保证项目代码质量的有力武器,但是有些业务场景,依赖的第三方没有测试环境,这时候该怎么做Unit Test呢,总不能直接生产环境硬来吧?
可以借助一些mock测试工具来解决这个难题(比如下面要讲的mockito),废话不多说,直奔主题:
一、准备示例Demo
假设有一个订单系统,用户可以创建订单,同时下单后要检测用户余额(如果余额不足,提醒用户充值),具体来说,里面有2个服务:OrderService、UserService,类图如下:
示例代码:
package com.cnblogs.yjmyzz.springbootdemo.service.impl; import com.cnblogs.yjmyzz.springbootdemo.service.UserService; import org.springframework.stereotype.Service; import java.math.BigDecimal; /** * @author 菩提树下的杨过 */ @Service("userService") public class UserServiceImpl implements UserService { @Override public BigDecimal queryBalance(int userId) { System.out.println("queryBalance=>userId:" + userId); //模拟返回100元余额 return new BigDecimal(100); } }
及
package com.cnblogs.yjmyzz.springbootdemo.service.impl; import com.cnblogs.yjmyzz.springbootdemo.service.OrderService; import com.cnblogs.yjmyzz.springbootdemo.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.math.BigDecimal; @Service("orderService") public class OrderServiceImpl implements OrderService { @Autowired private UserService userService; /** * 下订单 * * @param productName * @param orderNum * @return * @throws Exception */ @Override public Long createOrder(String productName, Integer orderNum, int userId) throws Exception { System.out.println("createOrder=>userId:" + userId); if (StringUtils.isEmpty(productName)) { throw new Exception("productName is empty"); } if (orderNum == null) { throw new Exception("orderNum is null!"); } if (orderNum <= 0) { throw new Exception("orderNum must bigger than 0"); } //下订单过程略,返回1L做为订单号 Long orderId = 1L; //模拟检测余额 BigDecimal balance = userService.queryBalance(userId); if (balance.compareTo(BigDecimal.TEN) <= 0) { System.out.println("余额不足10元,请及时充值!"); } return orderId; } }
里面的逻辑不是重点,随便看看就好。关注下createOrder方法,最后几行OrderService调用了UserService查询余额,即:OrderService依赖UserService,假设UserService就是一个第3方服务,不具备测试环境,本文就来讲讲如何对UserService进行mock测试。
二、pom引入mockito 及 jacoco plugin
2.1 引入mockito
1 <dependency> 2 <groupId>org.mockito</groupId> 3 <artifactId>mockito-all</artifactId> 4 <version>1.9.5</version> 5 <scope>test</scope> 6 </dependency>
mockito是一个mock工具库,马上会讲到用法。
2.2 引入jacoco插件
1 <plugin> 2 <groupId>org.jacoco</groupId> 3 <artifactId>jacoco-maven-plugin</artifactId> 4 <version>0.8.5</version> 5 <executions> 6 <execution> 7 <id>prepare-agent</id> 8 <goals> 9 <goal>prepare-agent</goal> 10 </goals> 11 </execution> 12 <execution> 13 <id>report</id> 14 <phase>prepare-package</phase> 15 <goals> 16 <goal>report</goal> 17 </goals> 18 </execution> 19 <execution> 20 <id>post-unit-test</id> 21 <phase>test</phase> 22 <goals> 23 <goal>report</goal> 24 </goals> 25 <configuration> 26 <dataFile>target/jacoco.exec</dataFile> 27 <outputDirectory>target/jacoco-ut</outputDirectory> 28 </configuration> 29 </execution> 30 </executions> 31 </plugin>
jacoco可以将单元测试的结果,直接生成html网页,分析代码覆盖率。注意 <outputDirectory>target/jacoco-ut</outputDirectory> 这一行的配置,表示将在target/jacoco-ut目录下生成测试报告。
注:如果最终按本文方法,没有生成测试报告,可先检测下test/target目录下,是否生成了jacoco.exec文件。如果没有,尝试将jacoco插件升级到最新版本。另外JDK 17环境,还需要配置一些参数,参考下面:
<plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>${jacoco.version}</version> <configuration> <dataFile>target/jacoco.exec</dataFile> <outputDirectory>target/jacoco-ut</outputDirectory> <excludes> <exclude> <!--需要排除test的部分,根据自己项目情况来--> **/yjmyzz/dal/**, **/yjmyzz/contract/**, **/yjmyzz/**/constants/**, **/yjmyzz/**/model/**, **/yjmyzz/config/**, **/yjmyzz/utils/** </exclude> </excludes> </configuration> <executions> <execution> <id>prepare-agent</id> <goals> <goal>prepare-agent</goal> </goals> </execution> <execution> <id>post-unit-test</id> <phase>test</phase> <goals> <goal>report</goal> </goals> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <configuration> <reuseForks>true</reuseForks> <argLine> ${argLine} -Xmx2048m --add-opens java.base/jdk.internal.util.random=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/sun.reflect.annotation=ALL-UNNAMED --add-opens java.base/java.math=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/sun.util.calendar=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED --add-opens java.xml/com.sun.org.apache.xerces.internal.jaxp.datatype=ALL-UNNAMED </argLine> </configuration> </plugin>
三、编写单测用例
3.1 约定大于规范
以OrderServiceImpl类为例,如果要对它做单元测试,建议按以下约定:
a. 在test/java下创建一个与OrderServiceImpl同名的package名(注:这样的好处是测试类与原类,处于同1个包,代码可见性相同)
b. 然后在该package下创建OrderServiceImplTest类(注意:一般测试类名的风格为 xxxxTest,在原类名后加Test)
3.2 单元测试模板
参考下面的代码模板:
package com.cnblogs.yjmyzz.springbootdemo.service.impl; import com.cnblogs.yjmyzz.springbootdemo.service.UserService; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.mockito.runners.MockitoJUnitRunner; @RunWith(MockitoJUnitRunner.class) public class OrderServiceImplTest { @Before public void setUp() { MockitoAnnotations.initMocks(this); } /** * 真正要测试的类 */ @InjectMocks private OrderServiceImpl orderService; /** * 测试类依赖的其它服务 */ @Mock private UserService userService; /** * createOrder成功时的用例 */ @Test public void testCreateOrderSuccess() { //todo } /** * createOrder失败时的用例 */ @Test public void testCreateOrderFailure() { //todo } }
讲解一下:
a. 类上的@RunWith要改成 MockitoJUnitRunner.class,否则mockito不生效
b. 真正需要测试的类,要用@InjectMocks,而不是@Mock(更不能是@Autowired)
-- 原因1:@Autowired是Spring的注解,在mock环境下,根本就没有Spring上下文,当然会注入失败。
-- 原因2:也不能是@Mock,@Mock表示该注入的对象是“虚构”的假对象,里面的方法代码根本不会真正运行,统一返回空对象null,即:被@Mock修饰的对象,在该测试类中,其具体的代码永远无法覆盖到!这也就是失败了单元测试的意义。而@InjectMocks修饰的对象,被测试的方法,才会真正进入执行。
另外,测试服务时,被mock注入的类,应该是具体的服务实现类,即:xxxServiceImpl,而不是服务接口,在mock环境中接口是无法实例化的。
c. 通常一个方法,会有运行成功和运行失败二种情况,建议测试类里,用testXXXSuccess以及testXXXFailure区分开来,看起来比较清晰。
3.3 测试覆盖率
先来看看下单失败的情况:下单前有很多参数校验,先验证下这些参数异常的场景。
public int userId = 101; /** * createOrder失败时的用例 */ @Test public void testCreateOrderWhenFail() { try { orderService.createOrder(null, 10, userId); } catch (Exception e) { Assert.assertEquals(true, true); } try { orderService.createOrder("book", null, userId); } catch (Exception e) { Assert.assertEquals(true, true); } try { orderService.createOrder("book", 0, userId); } catch (Exception e) { Assert.assertEquals(true, true); } try { orderService.createOrder("book", 50, userId); } catch (Exception e) { Assert.assertEquals(true, true); } }
命令行下mvn package 跑一下单元测试,全通过后,会在target/jacoco-ut 目录下生成网页报告
浏览器打开index.html,就能看到覆盖率
可以看到,中间那个带部分绿色的,就是我们刚才写过单测的pacakge,一层层点下去,能看到OrderServiceImpl.createOrder方法的代码覆盖情况,绿色的行表示覆盖到了,红色的表示未覆盖。
讲一个小技巧:有些类,比如DAO/Mytatis层自动生成的DO/Entity,还有一些常量定义等,其实没什么测试的必要,可以排除掉,这样不仅可以提高测试的覆盖率,还能让我们更关注于核心业务类的测试。
排除的方法很简单,可jacoco插件里配置exclude规则即可,参考下面这样:
<configuration> <dataFile>target/jacoco.exec</dataFile> <outputDirectory>target/jacoco-ut</outputDirectory> <excludes> <exclude> **/cnblogs/yjmyzz/**/aspect/**, **/yjmyzz/**/SampleApplication.class </exclude> </excludes> </configuration>
这样就把aspect包下的所有类,以及SampleApplication.class这个特定类给排除在单元测试之外,此时再跑一下mvn package ,对比下重新生成的报告
覆盖率从刚才的26%上升到了61%
3.4 mock返回值
从覆盖率上看,刚才createOrder方法里,最后几行并没有覆盖到,可以再写一个用例
问题来了,报异常了!分析下UserService的queryBalance方法实现
@Override public BigDecimal queryBalance(int userId) { System.out.println("queryBalance=>userId:" + userId); //模拟返回100元余额 return new BigDecimal(100); }
已经写死了返回100元,不应该为Null对象,同时还输出了一行日志,但是从测试结果来看,这个方法并没有真正执行。这也就印证了@Mock修饰的对象,是“假”的,并不会真正执行内部的代码
@Test public void testCreateOrderSuccess() throws Exception { BigDecimal balance = BigDecimal.TEN; //表示:当userService.queryBalance(userId)执行时,将返回balance变量做为返回值 when(userService.queryBalance(userId)).thenReturn(balance); long orderId = orderService.createOrder("phone", 10, userId); Assert.assertEquals(orderId, 1L); }
把测试代码调整下,改成上面这样,利用when(...).thenReturn(...),表示当xxx方法执行时,将模拟返回yyy对象。这样就mock出了userService的返回值
现在测试就通过了,再看看生成的测试报告,最后几行,也被覆盖到了。
出处:http://yjmyzz.cnblogs.com
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。