万字揭秘:助力单测提效,覆盖率八成无忧!

前言

近两年来,单测这玩意儿又火起来了!各厂开始重视单测通过、覆盖率;连带着,程序猿们的日常也多了一丝严谨。不再是简单的写写代码,提交提交bug,大家都开始追求那个绿绿的通过率。这不仅仅是为了指标要求,也是为了看到那一行行代码的满足感,更是为了团队的效率和软件质量的稳步提升。

说到单测,不得不说到那些古老的项目,代码行数能跑马拉松,单测啥的就是传说中的“空白篇”。看那代码,一坨坨的,层层调用,简直就像套娃,拆开一个又一个,没完没了。补存量代码的单测,这活儿啊,简直就是人看了心碎,狗看了流泪。这时候,团队合作的重要性就体现出来了。团队成员一条心,拿起武器就是干,那些年的单测债,终于是还清了。虽然过程枯燥无味、让人疲惫,但是看着那代码质量一步步上去,咱们也算是拨云见日,全身上下通透爽快!

我猜,其他团队的小伙伴们也有这种烦恼,今天在这里,我就把单测经验打包分享一番,希望能给大家带来点帮助,一起向着更高的代码质量冲刺!



解题思路

针对庞大的单测债务,我们要战略上进行藐视、战术上进行重视,不要有太大的压力,化繁为简,日拱一卒,具体思路如下:

mock大法:考虑项目的外部依赖、服务器成本、环境隔离等多方因素的限制,与其花费大量时间、成本在这些方面上,不如采用mock的形式。使用mock不仅能够规避外部依赖、解决成本问题,而且“预设-check”的形式,可以轻松模拟各种边界条件和异常情况,以测试代码的健壮性,总结一下就是:省事、省力、省成本

分而治之:将整个项目的测试任务细分成小块,比如按照模块、功能或者类别;确定哪些部分最关键、风险最高,优先编写这些部分的测试。遂以日常需求为本,将代码分为存量代码和增量代码,存量代码的单测从简处理,增量代码的单测认真覆盖。以重要程度为准,将代码分为核心代码和非核心代码;核心代码应详尽详,非核心代码量“时”而行。通过以上分析确定高低优先级,按模块或者功能补充单测。

工利其器:通过工具生成单测代码,研发在此基础上进行调参,进而覆盖大部分的逻辑分支,以此方式释放更多的时间,让大家聚焦更重要的工作。

重构优化:针对“坏味道”的代码进行重构重写,尤其是那种一个方法上千行、满篇if else for循环的代码。



落地实践

讲完思路,讲落地。面对蛛网一般错综复杂的调用链路,面对形形色色的接口形式,最终采用JUnit 4、Mockito组合的方式进行单元测试,具体的落地策略可以如下:

题外话:如今JUnit已经迭代到5.x,Mockito也进入了5.x阶段,在20年最开始补充单测的时候,项目用的是JUnit 5.x + Mockito 5.x,但因为遇到问题时可参考资料较少,随后降级到 JUnit 4.x + Mockito 3.x 实现。



说说为什么没有使用 PowerMockito

在mockito-inline中,已经支持mock静态类和方法、final 类等。已经能够满足日常的单测场景,在我使用PowerMockito的场景大部分都是mock一个类的私有方法。

那么大家不妨想想,什么情况下你需要mock一个私有方法?对,当你需要mock一个类的私有方法时,往往是因为这个类的公共方法或其他方法内部调用了这个私有方法。使用PowerMockito来mock私有方法需要@PrepareForTest注解,而这可能会导致JaCoCo等代码覆盖率工具无法正确测量被@PrepareForTest注解标记的类的覆盖率。这种情况下,我花了大量时间,浪费大量精力,写了大量代码,最后的到一个不能提升指标完成率的结果,我是不乐意的。

那么大家不妨再想想,我们mock的私有方法都是一些什么样的逻辑?要么是又臭又长的、要么是超多分支判断的,要么是一些公共使用的。这是我们应该考虑的是优化这类代码,以单测促重构。



技术栈

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>

POM插件配置

<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插件

解决方案:通过升级mapstruct版本至1.3.1版本及以上。

在mapstruct高版本中,生成的代码类上存在 @Generated 注解,该注解声明此类由程序自动生成,告诉jacoco在统计单测覆盖率或者其他审计工具进行审计时忽略该类。







第二步:反射出战,摧枯拉朽(进度: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;

    @Before
    public void before() {
        // 获取当前类加载器
        classLoader = Thread.currentThread().getContextClassLoader();
    }

    /**
     * 反射执行所有:pojo、enum、exlpame 等基础domain类。
     */
    @Test
    public void domainCoverTest() {
        // 获取class loader
        for (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%)

完成第二步,我们解决了非核心的边缘代码的单测,此时项目基本上会有一个20%~25%的基础覆盖率。下面我们就要对util、service、controller进行覆盖,此时我们可以使用工具帮助我们快速生成单测。推荐Diffblue、SquareTest或者TestMe,这三个工具都是用过,从功能和生成单测的质量排序:Diffblue > SquareTest > TestMe。

Diffblue是收费的,基于AI实现,国内好像没法办使用了,之前通过一些技数手段用过,单测效果very good!其配套能力丰富、能批量生成,生成单测的质量也是杠杠的。

SquareTest目前也收费了,复杂类的单测覆盖率在20~40%左右,简单的单测覆盖率在50~80%,综合平均覆盖率在30~50%左右,去年也是痛下决心花了数百大洋交了个懒人费。SquareTest官方网站: Squaretest - Java Unit Test Generator for IntelliJ IDEA (内含视频教程呦!)

TestMe是免费的,同样的单测质量和功能性比前两者有所不如,毕竟免费嘛,要求这么多干嘛😜。

实战效果如下图(采用SquareTest),红色框内的我也补充了部分类的详细单测,记忆中是从55%推到60%的,也就是说SquareTest将我的单测从26%推到了55%,对于这个有着10年年龄的代码库,能提升了29%还是满意的。







讲到单测工具,不得不提这两年很火的AI,上文提到的 Diffblue 就是基于AI实现。在我任职的公司也提供了内部的AI工具,名为:joyCoder。

在使用joyCoder时,其单测通过率相比SquareTest更高一些,覆盖率也更好一些,但使用过程中也有一些不便:

1.一个类的行数超过2000行,它就拒绝给我生成单测了

2.无法直接生成文件,需要创建单测类后再将生成代码copy进去。

3.需要告知joycoder单测要求,不然常常生成示例代码。

基于以上我将joyCoder作为增量代码的单测生成利器。将SquareTest作为存量代码的单测生成利器。

增量代码的单测在joyCoder的生成的基础上,在进行手动调参。完成超高的单测覆盖。



第四步:特例分析,内功小成(进度:60%~70%)

针对工具生成单测的时候一些覆盖不到的代码,手动调整时高频遇到的一些问题进行整理。



mock静态方法

以下示例中 SkuImportUtil 是一个静态类,有 public static 方法 getSkusFromExcel。

@Test
public void importSkus_setEx_true() {
    // mock MultipartFile
    MultipartFile file = new MockMultipartFile("file", "test.xlsx", "application/vnd.ms-excel", "excel".getBytes());
    // mock SkuImportUtil
    MockedStatic<SkuImportUtil> mocked = mockStatic(SkuImportUtil.class);
    mocked.when(() -> SkuImportUtil.getSkusFromExcel(eq(file), any(StringBuilder.class), anyInt())).thenReturn(Sets.newHashSet("1"));
    // mock cache
    when(cacheService.setEx(anyString(), anyString(), anyLong(), eq(false))).thenReturn(true);
    // call
    Result<String> result = performanceController.importSkus(file);
    assert ResultCodeEnum.SUCCESS.getCode().equals(result.getCode());
}



mock自调用(public方法)

自调用表示一个对象在其自身的其他方法中调用自己的方法,如存在一类,该类存在A、B两个方法,A方法内部调用本类的B方法。

这里mock的是自调用方法中的被public修饰的被调用方。

以下示例中存在 PerformanceController 类,存在两个public类:doSave 和 doUpdate,其中 doSave 方法内部调用了 doUpdate方法。

简化代码如下:

@RequestMapping("doSave")
@ResponseBody
public 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")
@ResponseBody
public 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
    @InjectMocks
    private PerformanceController performanceController;

    @Mock
    private CacheService cacheService;

    @Test
    public void save_import() {
        // mock cache
        when(cacheService.get(anyString())).thenReturn("[1,2]");
        // mock doUpdate
        doReturn(Result.success(true)).when(performanceController).doUpdate(any());
        // call
        PerformanceConfigParam param = new PerformanceConfigParam();
        param.setImportSkuKey("cacheKey");
        performanceController.doSave(param);
        // assert
        assert "0".equals(result.getCode());
    }
}

需注意,这里mock类需要使用 @Spy 进行标记,并且mock本类其他方法时,需要先 doXxx().when().xxx(); 不然会真实调用。



mock final类

在切面类中常常用到一个Method类,此类是一个final类,mock被final修饰的类可能引发不可预知的异常。

mock final类常常出现于AOP切面中,单测示例代码如下

@Test
public void secKey_empty() throws Throwable {
    // mock ProceedingJoinPoint
    ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);
    // mock MethodSignature
    MethodSignature 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 param
    JosBaseParam param = new JosOrderQuery();
    when(joinPoint.getArgs()).thenReturn(new Object[]{param});
    // mock duccConfig
    when(josDucc.getAppSecKeyConfig()).thenReturn(new HashMap<>());
    // call
    josSecKeyAspect.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的方法的参数等于某个值的时,才认为方法调用是正确的。

@Test
public 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 batchOriginalSetEx
    doNothing().when(cacheService).batchOriginalSetEx(anyList());
    // mock cache originalDel
    when(cacheService.originalDel(anyList())).thenReturn(1L);
    // call
    rSkuTemplateService.delSkuTemplateRelationByTemplateId(2L);
}



第五步:另辟蹊径,重增略存(进度:70%~85%)

此步骤旨在:基于研发资源长期紧张的情况下,重视增量单测,对新开发的功能进行重点测试,可以确保新加入的代码不会破坏现有的功能,保持软件质量。简略存量单测,对于已有的代码,如果资源有限,采取简略测试可以节省时间和成本。随着需求的更新换代,逐步完善单元测试,可以确保测试覆盖率随时间提高,而不是一开始就追求完美。



修改private方法的修饰符为public

参考【落地实践】中第四步【mock自调用(public方法)】的形式,但是个人不建议这样搞,毕竟破坏了封装特性,降低了类、方法的安全性。建议通过设计模式,如策略、装饰器、工厂等实现,或这将其抽成为辅助类



使用线上JSON化数据进行单测回放

此方案借鉴了公司的压测平台,通过录制生产环境的流量(包括入参、cookie等),并在预发环境进行回放压测。具体操作是,基于生产环境的入参JSON和各依赖服务的返回结果JSON作为录制的单元测试数据,在单元测试中通过jsonRead方法进行反序列化后执行。

引入POM 如下:

<!-- JSON 解析 -->
<dependency>
    <groupId>com.jayway.jsonpath</groupId>
    <artifactId>json-path</artifactId>
    <version>2.7.0</version>
</dependency>



json读取工具类

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
 */
@Slf4j
public 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;
        }
    }
}



单侧参数示例











单测代码示例

@Test
public 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);
}



单侧覆盖效果截图,准备了成功和失败的两个参数,覆盖率高达 97%(类中依赖较少,但是有很多的list转map,map遍历等行为)。







第六步:以单测促重构,登顶高峰(进度:85%~95%)

《重构:改善既有代码的设计》提出的观点强调了单元测试在软件开发中的重要性,尤其是在代码重构的过程中:

•安全保障:单元测试构建了重构的安全网,确保重构不会改变代码的预期行为。

•质量提升:单元测试促进开发者编写易于测试的模块化代码,间接提高代码质量。

•重构工具:单元测试不仅是检测质量的工具,还是推动代码结构优化的重要手段。

•代码文档:测试用例也充当了代码的实时文档,帮助理解和维护代码。

由此可见,单测不单单是质量检测工具,还是代码重构的重要手段,更是保证重构质量的重要工具。



那么在我们开发中如何重构呢?

1、不对自己不熟悉的代码进行重构(熟悉成本、试错成本、测试成本)

2、小步快进,如果要重构三层及以上依赖的防范则认为是架构级重构,需要完成至少2个架构师交叉评审、协调测试资源后才能重构。三层以下则认为是微重构,鼓励微重构。

3、一个类很多私有方法时,将私有方法转为辅助的函数类(方便单测、主次分离、主干清晰)。

4、多使用设计模式。

5、多使用分治思想。

6、精炼、抽象共有能力。



注意事项

1、上述中【落地实践】【第二步】反射注意事项。

建议单独建立一个maven module,并在最后编译。因为用到了类加载器,避免对其他单测的影响,放到最后执行,通过<module>的顺序实现,代码片段如下







因为同项目下存在war包模块,则需要对其进行改造,在编译不仅打了war包,还要打出jar包(不会对已有产生影响),需要在pom中配置plugin,如下

<!-- 将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

在使用Mockito进行单元测试时,如果你需要mock一个方法,其参数是Long这样的包装类型,并且这个参数可能为null,确实需要使用any()而不是anyLong()。

anyXxx()是用于匹配任何非null的xxx或Xxx类型的值,而any()则更加通用,可以匹配任何类型的值,包括null。

除此之外,还可以使用以下形式进行mock(个人比较喜欢此类方法)

when(mockOrderService.queryOrderById(any(Long.class))).thenReturn(new Order());
  • 25
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值