万字揭秘:助力单测提效,覆盖率八成无忧!
mock大法:考虑项目的外部依赖、服务器成本、环境隔离等多方因素的限制,与其花费大量时间、成本在这些方面上,不如采用mock的形式。使用mock不仅能够规避外部依赖、解决成本问题,而且“预设-check”的形式,可以轻松模拟各种边界条件和异常情况,以测试代码的健壮性,总结一下就是:省事、省力、省成本。
分而治之:将整个项目的测试任务细分成小块,比如按照模块、功能或者类别;确定哪些部分最关键、风险最高,优先编写这些部分的测试。遂以日常需求为本,将代码分为存量代码和增量代码,存量代码的单测从简处理,增量代码的单测认真覆盖。以重要程度为准,将代码分为核心代码和非核心代码;核心代码应详尽详,非核心代码量“时”而行。通过以上分析确定高低优先级,按模块或者功能补充单测。
工利其器:通过工具生成单测代码,研发在此基础上进行调参,进而覆盖大部分的逻辑分支,以此方式释放更多的时间,让大家聚焦更重要的工作。
重构优化:针对“坏味道”的代码进行重构重写,尤其是那种一个方法上千行、满篇if else for循环的代码。
说说为什么没有使用 PowerMockito
技术栈
POM依赖配置
<dependencies><!-- ===== 单测-begin ===== --><!-- mockito-core --><dependency><groupId>org.mockito</groupId><artifactId>mockito-core</artifactId><version>3.12.4</version><scope>test</scope></dependency><!-- mockito-inline 用于mock静态方法 --><dependency><groupId>org.mockito</groupId><artifactId>mockito-inline</artifactId><version>3.12.4</version><scope>test</scope><exclusions><exclusion><artifactId>mockito-core</artifactId><groupId>org.mockito</groupId></exclusion></exclusions></dependency><!-- ===== 单测-end ===== --></dependencies>
<plugin><groupId>org.jacoco</groupId><artifactId>jacoco-maven-plugin</artifactId><version>0.8.3</version><executions><!--在unit测试之前--><execution><id>pre-unit-test</id><goals><goal>prepare-agent</goal></goals><configuration><!--如果surefire插件有设置argLine,则jacoco参数必须以下面形式引入否则会出现surefire 插件的参数覆盖jacoco功能参数,无法生成 jacoco.exec 文件,导致覆盖率一直为0,可见下面surefire 插件配置--><propertyName>jacocoArgLine</propertyName></configuration></execution><!-- Default value 参照官网配置 https://www.eclemma.org/jacoco/trunk/doc/report-mojo.html --><execution><id>report</id><phase>test</phase><goals><goal>report</goal></goals></execution><!-- Default value 参照官网配置 https://www.eclemma.org/jacoco/trunk/doc/report-aggregate-mojo.html --><execution><id>report-aggregate</id><phase>test</phase><goals><goal>report-aggregate</goal></goals></execution></executions></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-report-plugin</artifactId><version>2.22.2</version></plugin><!--是maven里执行测试用例的插件,默认使用JUnit并执行测试用例(如果配置jacocoArgLine,则此插件配置要在最后,否则获取不到配置项)--><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>2.22.2</version><configuration><!--忽略测试失败配置:继续打印报告及执行其它module的测试--><testFailureIgnore>true</testFailureIgnore><!--跳过测试阶段配置--><skipTests>false</skipTests><!--argLine作用指定VM参数--><argLine>-Dfile.encoding=UTF-8 ${jacocoArgLine}</argLine><!--如果surefire插件有设置argLine,则下面jacoco必须设置propertyName来形式引入 否则会出现surefire 插件的参数覆盖jacoco功能参数,无法生成jacoco.exec 文件,导致覆盖率一直为0--></configuration></plugin>
第一步:清除杂兵,减少基数
此步骤旨在:消除需要覆盖的代码行的基数。如日常开发中的注解组件:lombok、mapstruct等,其注解会生成class文件。尤其是lombok的@Data、@Builder注解,一个10个字段的类其生成的eaquals和hash方法就不止20行,内部builder类代码行数不下原类的3倍,同时生成的代码分支较多,较难覆盖,我的解决思路是排除。
lombok插件
lombok.config 的文件,配置如下:## 声明根配置文件config.stopBubbling=true# 排除单测统计(起作用生成@Generated注解,避免jacoco扫描)lombok.addLombokGeneratedAnnotation=true
解决方案:通过升级mapstruct版本至1.3.1版本及以上。
第二步:反射出战,摧枯拉朽(进度:0%~25%)
此步骤旨在:节省非核心代码的单测编写时间,让大家有更多时间聚焦更重要的事情。如POJO类、Enum、Mybatis生成的的Example、Wrapper等类,这些类大多为setter、getter方法,尤其是Example和Wrapper类,还内置了一些eq、between、in等方法。这些方法的单测如果全部覆盖效果明显,但是意义不大,因此本人取巧,通过反射自动覆盖其setter、getter和一些常见的入参类型的方法。反射代码具体实现如下:
package com.xx.xx;import com.google.common.collect.Lists;import com.google.common.reflect.ClassPath;import lombok.extern.slf4j.Slf4j;import org.junit.Before;import org.junit.Test;import org.junit.runner.RunWith;import org.mockito.junit.MockitoJUnitRunner;import java.lang.reflect.Constructor;import java.lang.reflect.Method;import java.lang.reflect.Modifier;import java.math.BigDecimal;import java.util.ArrayList;import java.util.Date;import java.util.List;import java.util.Set;/*** @version 1.0.0* @date 2024-01-26 19:36*/@Slf4j@RunWith(MockitoJUnitRunner.class)public class PojoCoverTest {/*** 需要覆盖的包集合*/private final static List<String> POJO_PACKAGE_LIST = Lists.newArrayList(// --- domain"com.xx.xx.xx.cache.promotion","com.xx.xx.xx.domain","com.xx.xx.xx.dto","com.xx.xx.xx.enums","com.xx.xx.xx.json","com.xx.xx.xx.param","com.xx.xx.xx.pay","com.xx.xx.xx.result","com.xx.xx.xx.vo",//--- publish"com.xx.xx.xx.publish.base","com.xx.xx.xx.publish.result","com.xx.xx.xx.publish.vo",//--- rpc"com.xx.xx.xx.rpc.vo","com.xx.xx.xx.rpc.dto",//--- shop"com.xx.xx.xx.shop.param","com.xx.xx.xx.shop.result",//--- web"com.xx.xx.xx.web.bo","com.xx.xx.xx.web.config","com.xx.xx.xx.web.controller.param","com.xx.xx.xx.web.model","com.xx.xx.xx.web.vo",//--- service"com.xx.xx.xx.service.event","com.xx.xx.xx.service.factor.dto","com.xx.xx.xx.service.factor.enums","com.xx.xx.xx.service.impl.delivery.param","com.xx.xx.xx.service.param","com.xx.xx.xx.service.vo");/*** 类加载器*/private ClassLoader classLoader = null;@Beforepublic void before() {// 获取当前类加载器classLoader = Thread.currentThread().getContextClassLoader();}/*** 反射执行所有:pojo、enum、exlpame 等基础domain类。*/@Testpublic void domainCoverTest() {// 获取class loaderfor (String packageName : POJO_PACKAGE_LIST) {try {// 加载指定包以及子包的类ClassPath classPath = ClassPath.from(classLoader);Set<ClassPath.ClassInfo> classInfos = classPath.getTopLevelClassesRecursive(packageName);log.error(">>>>>>> domainCoverTest, packageName:{}, classSize:{}", packageName, classInfos.size());// 覆盖单测for (ClassPath.ClassInfo classInfo : classInfos) {this.coverDomain(classInfo.load());}} catch (Throwable e) {log.error(">>>>>>> domainCoverTest Exception package:{}", packageName, e);}}}private void coverDomain(Class<?> clazz) {boolean canInstance = this.canInstance(clazz);if (!canInstance) {return;}// 枚举,执行所有值if (clazz.isEnum()) {Object[] enumList = clazz.getEnumConstants();for (Object enumField : enumList) {// 输出每一行枚举值String enumString = enumField.toString();}}// 执行外部类的所有方法Object outerInstance = null;try {outerInstance = clazz.getDeclaredConstructor().newInstance();this.method(clazz, outerInstance);} catch (Throwable ignored) {}// 执行指定内部类的方法for (Class<?> innerClass : clazz.getDeclaredClasses()) {try {boolean innerCanInstance = this.canInstance(clazz);if (!innerCanInstance) {continue;}boolean isStatic = Modifier.isStatic(innerClass.getModifiers());Object innerClazzInstance = null;if (isStatic) {Constructor<?> constructor = innerClass.getDeclaredConstructor();constructor.setAccessible(true);innerClazzInstance = constructor.newInstance();} else {Constructor<?> constructor = innerClass.getDeclaredConstructor(clazz);constructor.setAccessible(true);innerClazzInstance = constructor.newInstance(outerInstance);}this.method(innerClass, innerClazzInstance);} catch (Throwable ignored) {}}}private boolean canInstance(Class<?> clazz) {int modifiers = clazz.getModifiers();boolean isAnnotation = clazz.isAnnotation();boolean isInterface = clazz.isInterface();boolean isEnum = clazz.isEnum();boolean isAbstract = Modifier.isAbstract(modifiers);boolean isNative = Modifier.isNative(modifiers);log.error(">>>>>>> coverDomain class:{}, isAnnotation:{}, isInterface:{}, isEnum:{}, isAbstract:{}, isNative:{}", clazz.getName(), isAnnotation, isInterface, isEnum, isAbstract, isNative);if (isAnnotation || isInterface || isAbstract || isNative) {return false;}// 如果是静态类或者final类,且不是枚举类也不处理return isEnum || (!Modifier.isFinal(modifiers));}/*** 通过反射调用指定实例的方法** @param clazz 方法所属的类对象* @param instance 方法所属的实例对象*/private void method(Class<?> clazz, Object instance) {for (Method method : clazz.getDeclaredMethods()) {if (!Modifier.isStatic(method.getModifiers())) {method.setAccessible(true);}Class<?>[] parameterTypes = method.getParameterTypes();try {if (parameterTypes.length == 0) {method.invoke(instance);} else {// null 值覆盖try {Object[] parameters = new Object[parameterTypes.length];for (int i = 0; i < parameterTypes.length; i++) {Class<?> paramType = parameterTypes[i];parameters[i] = this.getValue(paramType, true);}method.invoke(instance, parameters);} catch (Throwable ignore) {}// 非 null 值覆盖try {Object[] parameters = new Object[parameterTypes.length];for (int i = 0; i < parameterTypes.length; i++) {Class<?> paramType = parameterTypes[i];parameters[i] = this.getValue(paramType, false);}method.invoke(instance, parameters);} catch (Throwable ignore) {}}} catch (Throwable ignored) {}}}/*** 通过类的type 返回对应的默认值, 如果有其他类型请大家自行补充** @param type 入参字段类型* @return 返回对应字段的默认值*/private Object getValue(Class<?> type, boolean useNull) {if (type.isPrimitive()) {if (type.equals(boolean.class)) {return false;} else if (type.equals(char.class)) {return '\0';} else if (type.equals(byte.class)) {return (byte) 0;} else if (type.equals(short.class)) {return (short) 0;} else if (type.equals(int.class)) {return 0;} else if (type.equals(long.class)) {return 0L;} else if (type.equals(float.class)) {return 0F;} else if (type.equals(double.class)) {return 0.0;}}if (useNull) {return null;}if (type.equals(String.class)) {return "1";} else if (type.equals(Integer.class)) {return 1;} else if (type.equals(Long.class)) {return 1L;} else if (type.equals(Double.class)) {return 1.1D;} else if (type.equals(Float.class)) {return 1.1F;} else if (type.equals(Byte.class)) {return Byte.valueOf("1");} else if (type.equals(List.class)) {return new ArrayList<>();} else if (type.equals(Short.class)) {return Short.valueOf("1");} else if (type.equals(Date.class)) {return new Date();} else if (type.equals(Boolean.class)) {return true;} else if (type.equals(BigDecimal.class)) {return BigDecimal.ONE;} else {// 对于非原始类型和String,我们不提供默认值,即不传递参数return null;}}}
代码释义:将指定包名下的类通过反射执行代码逻辑;通过反射执行类以及内部类的方法,如方法参数为基本数据类型、Date、BigDecimal等则设置默认值进行覆盖(枚举遍历输出枚举值)。
实战效果如下图,单单以domain模块为例(其实在common、mananger、publish、rpc、task等模块中也有部分pojo类),如将domain推到100%,则全量单测覆盖率增加18%以上。加上其他模块的pojo类、enum类等,全量单测覆盖率基数提升25%不困难。
第三步:法宝致胜,快速生成(进度:25%~60%)
实战效果如下图(采用SquareTest),红色框内的我也补充了部分类的详细单测,记忆中是从55%推到60%的,也就是说SquareTest将我的单测从26%推到了55%,对于这个有着10年年龄的代码库,能提升了29%还是满意的。
讲到单测工具,不得不提这两年很火的AI,上文提到的 Diffblue 就是基于AI实现。京东也提供了内部的AI工具,名为:JoyCoder(https://www.jdcloud.com/cn/products/joycoder)。
第四步:特例分析,内功小成(进度:60%~70%)
mock静态方法
以下示例中 SkuImportUtil 是一个静态类,有 public static 方法 getSkusFromExcel。
@Testpublic void importSkus_setEx_true() {// mock MultipartFileMultipartFile file = new MockMultipartFile("file", "test.xlsx", "application/vnd.ms-excel", "excel".getBytes());// mock SkuImportUtilMockedStatic<SkuImportUtil> mocked = mockStatic(SkuImportUtil.class);mocked.when(() -> SkuImportUtil.getSkusFromExcel(eq(file), any(StringBuilder.class), anyInt())).thenReturn(Sets.newHashSet("1"));// mock cachewhen(cacheService.setEx(anyString(), anyString(), anyLong(), eq(false))).thenReturn(true);// callResult<String> result = performanceController.importSkus(file);assert ResultCodeEnum.SUCCESS.getCode().equals(result.getCode());}
mock自调用(public方法)
简化代码如下:
@RequestMapping("doSave")@ResponseBodypublic Result<String> doSave(@RequestBody PerformanceConfigParam param) {String pin = PinSupport.getPin();log.info("PerformanceController.doSave pin:{}, param:{}", pin, JSON.toJSONString(param));String importSkuKey = param.getImportSkuKey();if (StringUtils.isNotBlank(importSkuKey)) {// 导入模式 ...} else {// 录入模式 ...}// 参数校验 ...// 保存数据return this.doUpdate(param);}@RequestMapping("doUpdate")@ResponseBodypublic Result<String> doUpdate(@RequestBody PerformanceConfigParam param) {String pin = PinSupport.getPin();LogTypeEnum.DEFAULT.info("PerformanceController.doUpdate pin:{}, param:{}", pin, JSON.toJSONString(param));// 1.数据预校验 ...// 2.数据预处理 ...// 3.数据库操作 ...}
@RunWith(MockitoJUnitRunner.class)public class PerformanceControllerSpyTest {@Spy@InjectMocksprivate PerformanceController performanceController;@Mockprivate CacheService cacheService;@Testpublic void save_import() {// mock cachewhen(cacheService.get(anyString())).thenReturn("[1,2]");// mock doUpdatedoReturn(Result.success(true)).when(performanceController).doUpdate(any());// callPerformanceConfigParam param = new PerformanceConfigParam();param.setImportSkuKey("cacheKey");performanceController.doSave(param);// assertassert "0".equals(result.getCode());}}
需注意,这里mock类需要使用 @Spy 进行标记,并且mock本类其他方法时,需要先 doXxx().when().xxx(); 不然会真实调用。
mock final类
mock final类常常出现于AOP切面中,单测示例代码如下:
@Testpublic void secKey_empty() throws Throwable {// mock ProceedingJoinPointProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);// mock MethodSignatureMethodSignature signature = mock(MethodSignature.class);when(joinPoint.getSignature()).thenReturn(signature);// mock Method (final)Method method = mock(Method.class);when(method.getName()).thenReturn("mockMethod");when(signature.getMethod()).thenReturn(method);// mock paramJosBaseParam param = new JosOrderQuery();when(joinPoint.getArgs()).thenReturn(new Object[]{param});// mock duccConfigwhen(josDucc.getAppSecKeyConfig()).thenReturn(new HashMap<>());// calljosSecKeyAspect.around(joinPoint);}
mock多次调用不同结果
示例代码
public void delSkuTemplateRelationByTemplateId(Long templateId) {List<Long> skuList = skuGroupCacheUtil.getTemplateSkuIdsByTemplateIdNew(templateId);if (skuList == null) {return;}List<RSkuTemplateRelation> cacheList = new ArrayList<>();List<RSkuTemplateRelation> deleteList = new ArrayList<>();for (Long sku : skuList) {List<RSkuTemplateRelation> relationList = rSkuTemplateService.getSkuTemplateRelationBySku(sku);if (relationList == null) {continue;}for (RSkuTemplateRelation relation : relationList) {if (Long.valueOf("99").equals(relation.getModelId())) {deleteList.add(relation);} else {cacheList.add(relation);}}}cacheService.batchOriginalSetEx(cacheList);cacheService.originalDel(deleteList);}
示例单测
使用 eq() 进行匹配,它告诉Mockito只有当mock的方法的参数等于某个值的时,才认为方法调用是正确的。
@Testpublic void delSkuTemplateRelationByTemplateId() throws Exception {// mock 查询为空场景when(skuGroupCacheUtil.getTemplateSkuIdsByTemplateIdNew(anyLong())).thenReturn(Lists.newArrayList(1L, 2L, 3L));when(rSkuTemplateService.getSkuTemplateRelationBySku(eq(1L))).thenReturn(null);// mock 正常结果RSkuTemplateRelation r2 = new RSkuTemplateRelation();r2.setModelId(2L);when(rSkuTemplateService.getSkuTemplateRelationBySku(eq(2L))).thenReturn(Lists.newArrayList(r2));// mock 异常结果RSkuTemplateRelation r3 = new RSkuTemplateRelation();r3.setModelId(99L);when(rSkuTemplateService.getSkuTemplateRelationBySku(eq(3L))).thenReturn(Lists.newArrayList(r3));// mock cache batchOriginalSetExdoNothing().when(cacheService).batchOriginalSetEx(anyList());// mock cache originalDelwhen(cacheService.originalDel(anyList())).thenReturn(1L);// callrSkuTemplateService.delSkuTemplateRelationByTemplateId(2L);}
第五步:另辟蹊径,重增略存(进度:70%~85%)
修改private方法的修饰符为public
使用线上JSON化数据进行单测回放
引入POM 如下:
<!-- JSON 解析 --><dependency><groupId>com.jayway.jsonpath</groupId><artifactId>json-path</artifactId><version>2.7.0</version></dependency>
package com.xx.xx.xx;import com.alibaba.fastjson.JSON;import com.alibaba.fastjson.TypeReference;import com.jayway.jsonpath.DocumentContext;import com.jayway.jsonpath.JsonPath;import lombok.extern.slf4j.Slf4j;import java.io.File;import java.util.Objects;/*** JsonReadUtil* https://github.com/json-path/JsonPath** @version 1.0* @date 2021-04-26 15:54:00*/@Slf4jpublic class JsonReadUtil {/*** 从指定的文件路径中读取JSON数据并根据给定的JSON路径和类解析成指定的对象** @param filePath 文件路径* @param jsonPath JSON路径* @param clazz 解析的对象类型* @return 解析后的对象,如果读取失败则返回null* @throws Exception 读取过程中可能抛出的异常*/public <T> T readJson(String filePath, String jsonPath, Class<T> clazz) throws Exception {File file = new File(Objects.requireNonNull(this.getClass().getResource("/" + filePath)).toURI());DocumentContext ctx = JsonPath.parse(file);Object read = ctx.read(jsonPath);if (read != null) {return JSON.parseObject(JSON.toJSONString(read), clazz);} else {return null;}}/*** 从指定的JSON文件中读取指定路径的数据** @param filePath JSON文件路径* @param jsonPath JSON数据路径* @param type 数据类型的引用* @return 读取到的数据对象* @throws Exception 读取过程中可能出现的异常*/public <T> T readJson(String filePath, String jsonPath, TypeReference<T> type) throws Exception {File file = new File(Objects.requireNonNull(this.getClass().getResource("/" + filePath)).toURI());DocumentContext ctx = JsonPath.parse(file);Object read = ctx.read(jsonPath);if (read != null) {return JSON.parseObject(JSON.toJSONString(read), type);} else {return null;}}}
@Testpublic void validator_fail() throws Exception {PerformanceConfigParam param = new JsonReadUtil().readJson("mock/SavePre03ShopGroupValidatorTest.json", "$.fail_param", PerformanceConfigParam.class);param.setAvailableMap(new HashMap<Long, List<Long>>() {{put(1L, Lists.newArrayList(100069042303L, 2L));}});List<LocShopProductInfoVO> locShopList = new JsonReadUtil().readJson("mock/SavePre03ShopGroupValidatorTest.json", "$.fail_locShopList", new TypeReference<List<LocShopProductInfoVO>>() {});List<TemplateProviderRelation> tpRelationList = new JsonReadUtil().readJson("mock/SavePre03ShopGroupValidatorTest.json", "$.fail_tpRelationList", new TypeReference<List<TemplateProviderRelation>>() {});List<ServiceProvider> serviceProviderList = new JsonReadUtil().readJson("mock/SavePre03ShopGroupValidatorTest.json", "$.fail_serviceProviderList", new TypeReference<List<ServiceProvider>>() {});when(locShopDataEsSearchService.queryLocShopByShopIds(anyList())).thenReturn(locShopList);when(templateProviderRelationService.queryRelationByTemplateId(anyList())).thenReturn(tpRelationList);when(cacheService.setEx(anyString(), anyString(), anyLong(), anyBoolean())).thenReturn(true);when(serviceProviderDao.queryServiceProviderByIds(anyList())).thenReturn(serviceProviderList);savePre03ShopGroupValidator.validator(param);}
第六步:以单测促重构,登顶高峰(进度:85%~95%)
安全保障:单元测试构建了重构的安全网,确保重构不会改变代码的预期行为。
质量提升:单元测试促进开发者编写易于测试的模块化代码,间接提高代码质量。
重构工具:单元测试不仅是检测质量的工具,还是推动代码结构优化的重要手段。
代码文档:测试用例也充当了代码的实时文档,帮助理解和维护代码。
1、不对自己不熟悉的代码进行重构(熟悉成本、试错成本、测试成本);
2、小步快进,如果要重构三层及以上依赖的防范则认为是架构级重构,需要完成至少2个架构师交叉评审、协调测试资源后才能重构。三层以下则认为是微重构,鼓励微重构;
3、一个类很多私有方法时,将私有方法转为辅助的函数类(方便单测、主次分离、主干清晰);
4、多使用设计模式;
5、多使用分治思想;
6、精炼、抽象共有能力。
1、上述中【落地实践】【第二步】反射注意事项。
<!-- 将war下class打包附属jar,额外的操作不影响 --><plugin><artifactId>maven-jar-plugin</artifactId><version>3.2.0</version><executions><execution><id>make-a-jar</id><phase>package</phase><goals><goal>jar</goal></goals><configuration><classifier>classes</classifier><includes><include>**/*.class</include></includes></configuration></execution></executions></plugin>
war包模块改造完后,单测模块如何引入?如下:
2、包装类传null值要用any() mock
除此之外,还可以使用以下形式进行mock(个人比较喜欢此类方法)
when(mockOrderService.queryOrderById(any(Long.class))).thenReturn(new Order());推荐阅读