系统稳定型建设之单元测试Spock落地

背景

目前我们团队测试资源的缺乏,大部分开发人员测试的随意性,导致上线的错误率偏高,让系统稳定性处于较低水平,基于这一现状决定引入单元测试以提高系统稳定性

项目紧,没时间就不用写单测了吗

这是我们多数人不写单测的理由也是借口。但是我们要知道错误率是恒定的,需要的调试量也是固定的,用测试甚至线上环境调试并不能降低调试的量,只会降低调试效率。

如果说项目紧不写单测,看起来编码阶段省了一些时间,但如果存在问题,必然会在测试和线上花掉成倍甚至更多的成本来修复

单元测试与集成测试的区别

  • 单元测试: 又称模块测试,是针对软件设计的最小单位——程序模块进行正确性检验的测试工作
  • 集成测试: 也叫做组装测试。通常在单元测试的基础上,将所有的程序模块进行有序的、递增的测试。集成测试是检验程序单元或部件的接口关系,逐步集成为符合概要设计要求的程序部件或整个系统

像我们通常使用spirng boot 注解 @SpringBootTest就属于集成测试的一种

单元测试可以带来的好处

  • 提升软件质量 优质的单元测试可以保障开发质量和程序的鲁棒性。越早发现的缺陷,其修复的成本越低。
  • 促进代码优化 单元测试的编写者和维护者都是开发工程师,在这个过程当中开发人员会不断去审视自己的代码,从而(潜意识)去优化自己的代码。
  • 提升研发效率 编写单元测试,表面上是占用了项目研发时间,但是在后续的联调、集成、回归测试阶段,单测覆盖率高的代码缺陷少、问题已修复,有助于提升整体的研发效率。
  • 增加重构自信 代码的重构一般会涉及较为底层的改动,比如修改底层的数据结构等,上层服务经常会受到影响;在有单元测试的保障下,我们对重构出来的代码会多一份底气

目前主流的mock(单元测试)框架

系统稳定型建设之单元测试Spock落地

实际调研过程

最开始是准备选用testable-mock,因为是阿里开源的,使用起来也是非常轻量和简单的。由于以下原因直接放弃了这个mock框架

  1. 测试demo跑不起来
系统稳定型建设之单元测试Spock落地

社区小伙伴给的原因是jdk版本高于8就会有问题。至于如何解决就没有去深入研究了

  1. 项目进入维护状态
系统稳定型建设之单元测试Spock落地
  1. 作者自己对于该项目的一些否定
系统稳定型建设之单元测试Spock落地

为什么不用最流行的mockito

Mockito 目前在github上的star是最多的。但是看了下感觉这个mock框架平平无奇,比较常规,单元测试代码需要编写较多的Java代码。

在实际调研过程中最终发现美团使用的单元测试框架为Spock,觉得挺不错的

主要有亮点:

  1. 可以用更少的代码去实现单元测试
  2. 有更好的语义化,让你的单测代码可读性更高 。所以最终选用Spock Spock是基于Groovy语言编写测试用例。既然编写单元测试无法避免,就让写单元测试变得好玩一点。同时也让大家能够多学一门语言,扩展自己的技能,在高速内卷的行业更有竞争力。同时Groovy语言和Java本身没什么特别大的差异,上手难度比较低,学习成本也不算特别高

Groovy简单培训

由于Spock是基于Groovy语言编写的,所以我们这里先大致了解下Groovy语法

系统稳定型建设之单元测试Spock落地

思维导图链接

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

  1. 测试多条件分支
  • 待测试代码
public double calc(double income) {
        BigDecimal tax;
        BigDecimal salary = BigDecimal.valueOf(income);
        if (income <= 0) {
            return 0;
        }
        if (income > 0 && income <= 3000) {
            BigDecimal taxLevel = BigDecimal.valueOf(0.03);
            tax = salary.multiply(taxLevel);
        } else if (income > 3000 && income <= 12000) {
            BigDecimal taxLevel = BigDecimal.valueOf(0.1);
            BigDecimal base = BigDecimal.valueOf(210);
            tax = salary.multiply(taxLevel).subtract(base);
        } else if (income > 12000 && income <= 25000) {
            BigDecimal taxLevel = BigDecimal.valueOf(0.2);
            BigDecimal base = BigDecimal.valueOf(1410);
            tax = salary.multiply(taxLevel).subtract(base);
        } else if (income > 25000 && income <= 35000) {
            BigDecimal taxLevel = BigDecimal.valueOf(0.25);
            BigDecimal base = BigDecimal.valueOf(2660);
            tax = salary.multiply(taxLevel).subtract(base);
        } else if (income > 35000 && income <= 55000) {
            BigDecimal taxLevel = BigDecimal.valueOf(0.3);
            BigDecimal base = BigDecimal.valueOf(4410);
            tax = salary.multiply(taxLevel).subtract(base);
        } else if (income > 55000 && income <= 80000) {
            BigDecimal taxLevel = BigDecimal.valueOf(0.35);
            BigDecimal base = BigDecimal.valueOf(7160);
            tax = salary.multiply(taxLevel).subtract(base);
        } else {
            BigDecimal taxLevel = BigDecimal.valueOf(0.45);
            BigDecimal base = BigDecimal.valueOf(15160);
            tax = salary.multiply(taxLevel).subtract(base);
        }
        return tax.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();
    }
  • 单元测试代码
@Unroll
def "个税计算,收入:#income, 个税:#result"() {  expect: "when + then 的组合"
  CalculateTaxUtils.calc(income) == result
  where: "表格方式测试不同的分支逻辑"
  income || result
  -1     || 0
  0      || 0
  2999   || 89.97
  3000   || 90.0
  3001   || 90.1
  11999  || 989.9
  12000  || 990.0
  12001  || 990.2
  24999  || 3589.8
  25000  || 3590.0
  25001  || 3590.25
  34999  || 6089.75
  35000  || 6090.0
  35001  || 6090.3
  54999  || 12089.7
  55000  || 12090
  55001  || 12090.35
  79999  || 20839.65
  80000  || 20840.0
  80001  || 20840.45
}
系统稳定型建设之单元测试Spock落地

Spock和JUnit对比同一份单元测试的语法差异

  • 待测试代码
public StudentVO getStudentById(int id) {
        List<StudentDTO> students = studentDao.getStudentInfo();
        StudentDTO studentDTO = students.stream().filter(u -> u.getId() == id).findFirst().orElse(null);
        StudentVO studentVO = new StudentVO();
        if (studentDTO == null) {
            return studentVO;
        }
        studentVO.setId(studentDTO.getId());
        studentVO.setName(studentDTO.getName());
        studentVO.setSex(studentDTO.getSex());
        studentVO.setAge(studentDTO.getAge());
        // 邮编
        if ("上海".equals(studentDTO.getProvince())) {
            studentVO.setAbbreviation("沪");
            studentVO.setPostCode("200000");
        }
        if ("北京".equals(studentDTO.getProvince())) {
            studentVO.setAbbreviation("京");
            studentVO.setPostCode("100000");
        }
        return studentVO;
    }
系统稳定型建设之单元测试Spock落地

可以看到代码量和可阅读性是提升很多的

Spock

Spock 核心标签

  • given: 可选标签,前面不能有其他代码块,不能重复使用
  • when: when标签必须和 then标签一起出现
  • then:  与 when一起使用
  • expect:期望的行为,when-then的精简版
  • cleanup: 清理资源相关的
  • where:在方法的最后面出现,不能重复

简单例子

def "HashMap accepts null key"() {
  given:
  def map = new HashMap()

  when:

  map.put(null"elem")

  then:

  notThrown(NullPointerException)
}
given:
def file = new File("/some/path")
file.createNewFile()

// ...

cleanup:

file.delete()

with表示期望值

def "offered PC matches preferred configuration"() {
  when:
  def pc = shop.buyPc()

  then:

  with(pc) {
    vendor == "Sunny"
    clockRate >= 2333
    ram >= 406
    os == "Linux"
  }
}

spock基本方法

  • 每个测试运行前的启动方法
def setup() {}
  • 每个测试运行后的清理方法
def cleanup() {}
  • 第一个测试运行前的启动方法
def setupSpec() {}
  • 最后一个测试运行后的清理方法
def cleanupSpec() {}

注意无论多么简单的测试,至少要有一个 expect: 块 或 when-then 块(别漏了在测试代码前加个 expect: 标签), 否则 Spock 会报 “No Test Found” 的错误

测试案例

1.引入依赖


<dependency>
    <groupId>org.spockframework</groupId>
    <artifactId>spock-core</artifactId>
    <version>1.3-groovy-2.4</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.codehaus.groovy</groupId>
    <artifactId>groovy-all</artifactId>
    <version>2.4.6</version>
</dependency>

2. 不同用例测试

多 if else 测试

  • 待测试代码
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(68) + "-"
                    + certificateNo.substring(810) + "-"
                    + certificateNo.substring(1012);
            sex = Integer.parseInt(certificateNo.substring(certificateNo.length() - 3,
                    certificateNo.length())) % 2 == 0 ? "女" : "男";
            age = (year - Integer.parseInt("19" + certificateNo.substring(68))) + "";
        } else if (flag && certificateNo.length() == 18) {
            birthday = certificateNo.substring(610) + "-"
                    + certificateNo.substring(1012) + "-"
                    + certificateNo.substring(1214);
            sex = Integer.parseInt(certificateNo.substring(certificateNo.length() - 4,
                    certificateNo.length() - 1)) % 2 == 0 ? "女" : "男";
            age = (year - Integer.parseInt(certificateNo.substring(610))) + "";
        }
        Map<String, String> map = new HashMap<>();
        map.put("birthday", birthday);
        map.put("age", age);
        map.put("sex", sex);
        return map;
    }
  • 测试代码
class IDNumberUtilsTest extends Specification {

    @Unroll
    def "身份证号:#idNo 的生日,性别,年龄是:#result"() {
        expect: "when + then 组合"
        IDNumberUtils.getBirAgeSex(idNo) == result

        where:
 "表格方式测试不同的分支逻辑"
        idNo                 || result
        "310168199809187333" || ["birthday""1998-09-18""sex""男""age""22"]
        "320168200212084268" || ["birthday""2002-12-08""sex""女""age""18"]
        "330168199301214267" || ["birthday""1993-01-21""sex""女""age""27"]
        "411281870628201"    || ["birthday""1987-06-28""sex""男""age""33"]
        "427281730307862"    || ["birthday""1973-03-07""sex""女""age""47"]
        "479281691111377"    || ["birthday""1969-11-11""sex""男""age""51"]
    }
}

void方法测试

  • 待测试代码
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);
    }
}
  • 测试代码
class UserServiceTest extends Specification {
    def userService = new UserService()
    def moneyDAO = Mock(MoneyDAO)

    void setup() {
        userService.userDao = userDao
        userService.moneyDAO = moneyDAO
    }

    def "测试void方法"() {
        given: "设置请求参数"
        def userVO = new UserVO(name:"James"country: "美国")
        userVO.userOrders = [new OrderVO(orderNum: "1"amount: 10000), new OrderVO(orderNum: "2"amount: 1000)]

        when:
 "调用设置订单金额的方法"
        userService.setOrderAmountByExchange(userVO)

        then:
 "验证调用获取最新汇率接口的行为是否符合预期: 一共调用2次, 第一次输出的汇率是0.1413, 第二次是0.1421"
        2 * moneyDAO.getExchangeByCountry(_) >> 0.1413 >> 0.1421

        and:
 "验证根据汇率计算后的金额结果是否正确"
        with(userVO){
            userOrders[0].amount == 1413
            userOrders[1].amount == 142.1
        }
    }
}

异常测试

  • 待测试代码
public void validateStudent(StudentVO student) throws BusinessException {
        if(student == null){
            throw new BusinessException("10001""student is null");
        }
        if(StringUtils.isBlank(student.getName())){
            throw new BusinessException("10002""student name is null");
        }
        if(student.getAge() == null){
            throw new BusinessException("10003""student age is null");
        }
        if(StringUtils.isBlank(student.getTelephone())){
            throw new BusinessException("10004""student telephone is null");
        }
        if(StringUtils.isBlank(student.getSex())){
            throw new BusinessException("10005""student sex is null");
        }
    }
  • 测试代码
@Unroll
    def "validate student info: #expectedMessage"() {
        when: "校验"
        tester.validateStudent(student)

        then:
 "验证"
        def exception = thrown(expectedException)
        exception.code == expectedCode
        exception.message == expectedMessage

        where:
 "测试数据"
        student           || expectedException | expectedCode | expectedMessage
        getStudent(10001) || BusinessException | "10001"      | "student is null"
        getStudent(10002) || BusinessException | "10002"      | "student name is null"
        getStudent(10003) || BusinessException | "10003"      | "student age is null"
        getStudent(10004) || BusinessException | "10004"      | "student telephone is null"
        getStudent(10005) || BusinessException | "10005"      | "student sex is null"
    }

    def getStudent(code) {
        def student = new StudentVO()
        def condition1 = {
            student.name = "张三"
        }
        def condition2 = {
            student.age = 20
        }
        def condition3 = {
            student.telephone = "12345678901"
        }
        def condition4 = {
            student.sex = "男"
        }

        switch (code) {
            case 10001:
                student = null
                break
            case 10002:
                student = new StudentVO()
                break
            case 10003:
                condition1()
                break
            case 10004:
                condition1()
                condition2()
                break
            case 10005:
                condition1()
                condition2()
                condition3()
                break
        }
        return student
    }

项目实战

1.idea快捷生成测试代码

系统稳定型建设之单元测试Spock落地
系统稳定型建设之单元测试Spock落地

2. 测试聚合根中的业务方法

  • 待测试代码

public void review(FinancePurchaseOrderBillReviewObj reviewObj) {

    // 校验是否存在异常未审批完成
    financeExceptionObjs.stream().filter(s -> Objects.equals(s.getReviewResultEnum(), ReviewResultEnum.PENDING)).findFirst().ifPresent(s -> {
        throw new BizException("存在异常审批未处理");
    });
    ReviewTypeEnum reviewType = reviewObj.getReviewType();
    if (Objects.equals(reviewType, ReviewTypeEnum.FOLLOW)) {
        this.billStatusEnum = BillStatusEnum.FOLLOW_ORDER_FAIL;
        this.approved = LocalDateTime.now();
    }
    if (Objects.equals(reviewType, ReviewTypeEnum.SUPPLIER)) {
        this.billStatusEnum = BillStatusEnum.FOLLOW_ORDER_PASS;
    }
    if (Objects.equals(reviewType, ReviewTypeEnum.DIRECTOR)) {
        this.billStatusEnum = BillStatusEnum.SUPPLIER_FAIL;
        this.ciderTime = LocalDateTime.now();
    }
    financePurchaseOrderBillReviewObjs.add(reviewObj);
    this.followerId = reviewObj.getUid();

}
  • 单元测试代码
@Unroll
def "Review reviewType:#reviewType"() {
    expect:
    def billAgg = new BillAgg(id:1,financeExceptionObjs:financeExceptionObjs)
    def obj = new FinancePurchaseOrderBillReviewObj(reason: "hahah",reviewType:reviewType, uid:uid)

    when:

    billAgg.review(obj)
    then:
    with(billAgg){
        id == 1
        billStatusEnum == billStatusEnumResult
        followerId == followerIdResult
    }
    where: "测试数据"
    reviewType| uid ||   billStatusEnumResult | financeExceptionObjs | followerIdResult
    ReviewTypeEnum.FOLLOW| 1 || BillStatusEnum.FOLLOW_ORDER_FAIL | createFinanceExceptionObjs(false)| 1
    ReviewTypeEnum.SUPPLIER| 2 || BillStatusEnum.FOLLOW_ORDER_PASS | createFinanceExceptionObjs(false)| 2
    ReviewTypeEnum.DIRECTOR| 3 || BillStatusEnum.SUPPLIER_FAIL | createFinanceExceptionObjs(false)| 3

}

def createFinanceExceptionObjs(def create) {
    if (create) {
        return [new FinanceExceptionObj(id:1reason:"测试不通过",price:20.2)]
    }
    return [new FinanceExceptionObj()]

}

def "Review exception"() {
    given: "数据生成"
    def financeExceptionObjs = [new FinanceExceptionObj(id:1reviewResultEnum:ReviewResultEnum.PENDING)]
    def billAgg = new BillAgg(id:1,financeExceptionObjs:financeExceptionObjs)
    def obj = new FinancePurchaseOrderBillReviewObj(reason: "hahah",reviewType:ReviewTypeEnum.FOLLOW, uid:1status: ReviewResultEnum.PENDING)

    when:
 "方法调用"
    billAgg.review(obj)

    then:
 "结果验证"
    def exception = thrown(BizException)
    exception.getMessage() == "存在异常审批未处理"
}
系统稳定型建设之单元测试Spock落地

3. 查看单元测试覆盖率

系统稳定型建设之单元测试Spock落地
系统稳定型建设之单元测试Spock落地

有绿色点代表已覆盖到,粉色的点代表未覆盖到.

实际我上面的测试用例都是覆盖到了,这里只是为了演示,所以图片是这样的

有数据库相关操作的mock测试

  • 待测试代码
public ActionEnum reviewBill(BillReviewDTO billReviewDTO) {

    BillAgg billAgg = financeRepository.getBillAgg(billReviewDTO.getId());
    FinancePurchaseOrderBillReviewObj reviewObj = billConverter.toFinancePurchaseOrderBillReviewObj(billReviewDTO);
    billAgg.review(reviewObj);
    financeRepository.updateBillAgg(billAgg);
    // 发送领域事件
    if (billReviewDTO.fail()) {
        BillApprovalFailEvent event = new BillApprovalFailEvent(billReviewDTO.getId());
        domainEventBus.publishDomainEvent(event);
    }
    return ActionEnum.SUCCESS;
}
  • 测试代码
class BillApplicationServiceTest extends Specification {

    def financeRepository = Mock(FinanceRepository)
    def service = new BillApplicationService()
    def billConverter = new BillConverter()
    def domainEventBus = Mock(DomainEventBus)

    void setup() {
        service.financeRepository = financeRepository
        service.billConverter = billConverter
        service.domainEventBus = domainEventBus
    }

    @Unroll
    def "reviewBill status:#status reviewType:#reviewType"() {
        given: "设置请求参数"
        def billAgg = new BillAgg(id: 1l, purchaseOrderId: 10023l, purchaseOrderCount: 2)
        def pushEvent = false
        and: "mock掉接口返回的聚合根"
        financeRepository.getBillAgg(_) >> billAgg
        financeRepository.updateBillAgg(billAgg) >> {}
        domainEventBus.publishDomainEvent(_) >> { pushEvent = true}
        when: "调用账单审核"
        def response = service.reviewBill(reviewDTO)


        then:
 "验证结果正确性"
        with(billAgg) {
            id == 1L
            billStatusEnum == billStatusEnumResult
            pushEvent == pushEventResult

        }

        where:
 "测试数据"
        reviewDTO || billStatusEnumResult | pushEventResult
        getBillReviewDTO(00) || BillStatusEnum.FOLLOW_ORDER_FAIL | true
        getBillReviewDTO(10) || BillStatusEnum.FOLLOW_ORDER_FAIL | true


    }

    def getBillReviewDTO(status, reviewType) {
        def reviewDTO = new BillReviewDTO(id: 1l, uid: 2l, status:status, reviewType:reviewType)
        return reviewDTO
    }


}

参考资料

Spock单元测试框架介绍以及在美团优选的实践

Spock如何解决传统单元测试开发中的痛点 – 老K的Java博客

写有价值的单元测试

Spock官方文档


原文始发于微信公众号(小奏技术):系统稳定型建设之单元测试Spock落地

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/30001.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!