4年前 (2020-08-01)  Spock系列 |   抢沙发  4136 
文章评分 4 次,平均分 5.0

这是Spock系列的第二篇文章,上一篇介绍了Spock的特点以及它和JUnit,JMock的区别,这篇主要讲下我们平时写单元测试过程中遇到的几种常见问题,分别使用JUnit和Spock如何解决,通过对比的方式给大家一个整体认识。

一. 单元测试代码开发的成本和效率

复杂场景的业务代码,在分支(if/else)很多的情况下,编写单测代码的成本会相应增加,正常的业务代码或许只有几十行,但为了测试这个功能,要覆盖大部分的分支场景,写的测试代码可能远远不止几十行

举个我们生产环境前不久发生的一起事故:有个功能上线1年多一直都正常,没有出过问题,但最近有个新的调用方请求的数据不一样,走到了代码中一个不常用的分支逻辑,导致了bug,直接抛出异常阻断了主流程,好在调用方请求量不大。。。

估计当初写这段代码的同学也认为很小几率会走到这个分支,虽然当时也写了单元测试代码,但分支较多,刚好漏掉了这个分支逻辑的测试,给日后上线留下了隐患

这也是我们平时写单元测试最常遇到的问题:要达到分支覆盖率高要求的情况下,if/else有不同的结果,传统的单测写法可能要多次调用,才能覆盖全部的分支场景,一个是写单测麻烦,同时也会增加单测代码的冗余度

虽然可以使用junit的@parametered参数化注解或者dataprovider的方式,但还是不够方便直观,而且如果其中一次分支测试case出错的情况下,报错信息也不够详尽。

比如下面的示例演示代码,根据输入的身份证号码识别出生日期、性别、年龄等信息,这个方法的特点就是有很多if...else...的分支嵌套逻辑

/**
 * 身份证号码工具类<p>
 * 15位:6位地址码+6位出生年月日(900101代表1990年1月1日出生)+3位顺序码
 * 18位:6位地址码+8位出生年月日(19900101代表1990年1月1日出生)+3位顺序码+1位校验码
 * 顺序码奇数分给男性,偶数分给女性。
 * @author 公众号:Java老K
 * 个人博客:www.javakk.com
 */
public class IDNumberUtils {
    /**
     * 通过身份证号码获取出生日期、性别、年龄
     * @param certificateNo
     * @return 返回的出生日期格式:1990-01-01   性别格式:F-女,M-男
     */
    public static Map<String, String> getBirAgeSex(String certificateNo) {
        String birthday = "";
        String age = "";
        String sex = "";

        int year = Calendar.getInstance().get(Calendar.YEAR);
        char[] number = certificateNo.toCharArray();
        boolean flag = true;
        if (number.length == 15) {
            for (int x = 0; x < number.length; x++) {
                if (!flag) return new HashMap<>();
                flag = Character.isDigit(number[x]);
            }
        } else if (number.length == 18) {
            for (int x = 0; x < number.length - 1; x++) {
                if (!flag) return new HashMap<>();
                flag = Character.isDigit(number[x]);
            }
        }
        if (flag && certificateNo.length() == 15) {
            birthday = "19" + certificateNo.substring(6, 8) + "-"
                    + certificateNo.substring(8, 10) + "-"
                    + certificateNo.substring(10, 12);
            sex = Integer.parseInt(certificateNo.substring(certificateNo.length() - 3,
                    certificateNo.length())) % 2 == 0 ? "女" : "男";
            age = (year - Integer.parseInt("19" + certificateNo.substring(6, 8))) + "";
        } else if (flag && certificateNo.length() == 18) {
            birthday = certificateNo.substring(6, 10) + "-"
                    + certificateNo.substring(10, 12) + "-"
                    + certificateNo.substring(12, 14);
            sex = Integer.parseInt(certificateNo.substring(certificateNo.length() - 4,
                    certificateNo.length() - 1)) % 2 == 0 ? "女" : "男";
            age = (year - Integer.parseInt(certificateNo.substring(6, 10))) + "";
        }
        Map<String, String> map = new HashMap<>();
        map.put("birthday", birthday);
        map.put("age", age);
        map.put("sex", sex);
        return map;
    }
}

针对上面这种场景,spock提供了where标签,让我们可以通过表格的方式方便测试多种分支

下面的对比图是针对"根据身份证号码获取出生日期、性别、年龄"方法实现的单元测试,左边是我们常用的Junit的写法,右边是Spock的写法,红框圈出来的是一样的功能在Junit和Spock上的代码实现 (两边执行的单测结果一样,点击放大查看差异)

Spock如何解决传统单元测试开发中的痛点

对比结果:

右边一栏使用Spock写的单测代码上语法简洁,表格方式测试覆盖多分支场景也更直观,提升开发效率,更适合敏捷开发

(关于Spock代码的具体语法会在后续文章讲解)

二. 单元测试代码的可读性和后期维护

微服务架构下,很多场景需要依赖其他接口返回的结果才能验证自己代码的逻辑,这样就需要使用mock工具,但JMock或Mockito的语法比较繁琐,再加上单测代码不像业务代码那么直观,不能完全按照业务流程的思路写单测,以及开发同学对单测代码可读性的不重视,最终导致测试代码难于阅读,维护起来更是难上加难

可能自己写完的测试,过几天再看就云里雾里了(当然添加注释会好很多),再比如改了原来的代码逻辑导致单测执行失败,或者新增了分支逻辑,单测没有覆盖到,随着后续版本的迭代,会导致单测代码越来越臃肿和难以维护

Spock提供多种语义标签,如: given、when、then、expect、where、with、and 等,从行为上规范单测代码,每一种标签对应一种语义,让我们的单测代码结构具有层次感,功能模块划分清晰,便于后期维护

Spock自带mock功能,使用上简单方便(Spock也支持扩展第三方mock框架,比如power mock)保证代码更加规范,结构模块化,边界范围清晰,可读性强,便于扩展和维护,用自然语言描述测试步骤,让非技术人员也能看懂测试代码

比如下面的业务代码:

调用用户接口或者从数据库获取用户信息,然后做一些转换和判断逻辑(这里的业务代码只是列举常见的业务场景,方便演示)

/**
 * 用户服务
 * @author 公众号:Java老K
 * 个人博客:www.javakk.com
 */
@Service
public class UserService {

    @Autowired
    UserDao userDao;

    @Autowired
    MoneyDAO moneyDAO;

    public UserVO getUserById(int uid){
        List<UserDTO> users = userDao.getUserInfo();
        UserDTO userDTO = users.stream().filter(u -> u.getId() == uid).findFirst().orElse(null);
        UserVO userVO = new UserVO();
        if(null == userDTO){
            return userVO;
        }
        userVO.setId(userDTO.getId());
        userVO.setName(userDTO.getName());
        userVO.setSex(userDTO.getSex());
        userVO.setAge(userDTO.getAge());
        // 显示邮编
        if("上海".equals(userDTO.getProvince())){
            userVO.setAbbreviation("沪");
            userVO.setPostCode(200000);
        }
        if("北京".equals(userDTO.getProvince())){
            userVO.setAbbreviation("京");
            userVO.setPostCode(100000);
        }
        // 手机号处理
        if(null != userDTO.getTelephone() && !"".equals(userDTO.getTelephone())){
            userVO.setTelephone(userDTO.getTelephone().substring(0,3)+"****"+userDTO.getTelephone().substring(7));
        }

        return userVO;
    }
}

下面的对比图是分别使用Junit和Spock实现的单元测试,左边是Junit的写法,右边是Spock,红框圈出来的是一样的功能在Junit和Spock上的实现 (两边执行的单测结果一样,点击放大查看差异)

Spock如何解决传统单元测试开发中的痛点

对比结果:

左边的junit单测代码冗余,缺少结构层次,可读性差,随着后续迭代势必会导致代码的堆积,后期维护成本会越来越高。

右边的单测代码spock会强制要求使用given、when、then这样的语义标签(至少一个),否则编译不通过,这样保证代码更加规范,结构模块化,边界范围清晰,可读性强,便于扩展和维护,用自然语言描述测试步骤,让非技术人员也能看懂测试代码(given表示输入条件,when触发动作,then验证输出结果)

Spock自带的mock语法也非常简单:

"userDao.getUserInfo() >> [user1, user2]"

两个右箭头">>"表示即模拟getUserInfo接口的返回结果,再加上使用的groovy语言,可以直接使用"[]"中括号表示返回的是List类型(具体语法会在下一篇讲到)

三. 单元测试不仅仅是为了达到覆盖率统计,更重要的是验证业务代码的健壮性、逻辑的严谨性以及设计的合理性

在项目初期为了赶进度,可能没时间写单测,或者这个时期写的单测只是为了达到覆盖率要求(因为有些公司在发布前会使用jacoco等单测覆盖率工具来设置一个标准,比如新增代码必须达到80%的覆盖率才能发布)

再加上传统的单测是使用java这种强类型语言写的,以及各种底层接口的mock导致写起单测来繁琐费时

这时写的单测代码比较粗糙,颗粒度比较大,缺少对单测结果值的有效验证,这样的单元测试对代码质量的验证和提升无法完全发挥作用,更多的是为了测试而测试

最后大家不得不接受“虽然写了单测,但却没什么鸟用”的结果

比如下面这段业务代码示例:

void方法,没有返回结果,如何写单测测试这段代码的逻辑是否正确?即如何知道单测代码是否执行到了for循环里面的语句(可以通过查看覆盖率或打断点的方式确认,但这样太麻烦了),如何确保循环里面的金额是否计算正确?

大家可以想下使用junit的方式写单元测试如何验证这几点?

/**
 * 用户服务
 * @author 公众号:Java老K
 * 个人博客:www.javakk.com
 */
@Service
public class UserService {

    @Autowired
    MoneyDAO moneyDAO;

    /**
     * 根据汇率计算金额
     * @param userVO
     */
    public void setOrderAmountByExchange(UserVO userVO){
        if(null == userVO.getUserOrders() || userVO.getUserOrders().size() <= 0){
            return ;
        }
        for(OrderVO orderVO : userVO.getUserOrders()){
            BigDecimal amount = orderVO.getAmount();
            // 获取汇率(调用汇率接口)
            BigDecimal exchange = moneyDAO.getExchangeByCountry(userVO.getCountry());
            amount = amount.multiply(exchange); // 根据汇率计算金额
            orderVO.setAmount(amount);
        }
    }
}

使用Spock写的话就会方便很多,如下图所示:

Spock如何解决传统单元测试开发中的痛点

其中:

"2 * moneyDAO.getExchangeByCountry(_) >> 0.1413 >> 0.1421"

这行代码表示在for循环中一共调用了2次获取汇率的接口,第一次汇率结果是0.1413,第二次是0.1421,(模拟汇率接口的实时变动),然后在with里验证,类似于junit里的assert断言,验证汇率折算后的人民币价格是否正确(完整代码会在后续文章中列出)

这样的好处就是:

提升单测代码的可控性,方便验证业务代码的逻辑正确和是否合理, 这正是BDD(行为驱动开发)思想的一种体现

因为代码的可测试性是衡量代码质量的重要标准, 如果代码不容易测试, 那就要考虑重构了, 这也是单元测试的一种正向作用

这一篇文章从3个方面对比展示了Spock的特点和优势,后面会详细讲解Spock的各种用法(结合具体的业务场景),以及groovy的一些语法和注意事项

 

除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/264.html

关于

发表评论

表情 格式

暂无评论

登录

忘记密码 ?

切换登录

注册