IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 开发测试 -> 系统稳定型建设之单元测试Spock落地 -> 正文阅读

[开发测试]系统稳定型建设之单元测试Spock落地

这里是weihubeats,觉得文章不错可以关注公众号小奏技术,文章首发。拒绝营销号,拒绝标题党

背景

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

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

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

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

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

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

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

单元测试可以带来的好处

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

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

实际调研过程

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

  1. 测试demo跑不起来

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

  1. 项目进入维护状态

  1. 作者自己对于该项目的一些否定

为什么不用最流行的mockito

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

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

主要有亮点:

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

Groovy简单培训

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

思维导图链接

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和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 核心标签

  • 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(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;
    }
  • 测试代码
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快捷生成测试代码

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:1, reason:"测试不通过",price:20.2)]
    }
    return [new FinanceExceptionObj()]

}

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

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

    then: "结果验证"
    def exception = thrown(BizException)
    exception.getMessage() == "存在异常审批未处理"
}

3. 查看单元测试覆盖率

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

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

有数据库相关操作的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(0, 0) || BillStatusEnum.FOLLOW_ORDER_FAIL | true
        getBillReviewDTO(1, 0) || 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官方文档

  开发测试 最新文章
pytest系列——allure之生成测试报告(Wind
某大厂软件测试岗一面笔试题+二面问答题面试
iperf 学习笔记
关于Python中使用selenium八大定位方法
【软件测试】为什么提升不了?8年测试总结再
软件测试复习
PHP笔记-Smarty模板引擎的使用
C++Test使用入门
【Java】单元测试
Net core 3.x 获取客户端地址
上一篇文章      下一篇文章      查看所有文章
加:2022-04-04 12:40:45  更:2022-04-04 12:41:18 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/18 0:39:05-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码