首页 >> 大全

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

2023-11-12 大全 33 作者:考证青年

点击上方“小强的进阶之路”,选择“星标”公众号

优质文章,及时送达

预计阅读时间: 29分钟

Spock是国外一款优秀的测试框架,基于BDD(行为驱动开发)思想实现,功能非常强大。Spock结合动态语言的特点,提供了各种标签,并采用简单、通用、结构化的描述语言,让编写测试代码更加简洁、高效。目前,美团优选物流绝大部分后端服务已经采用了Spock作为测试框架,在开发效率、可读性和维护性方面均取得了不错的收益。

1. 背景

XML之父Tim Bray最近在博客里有个好玩的说法:“代码不写测试就像上了厕所不洗手……单元测试是对软件未来的一项必不可少的投资。”具体来说,单元测试有哪些收益呢?

这张图来自微软的统计数据:Bug在单元测试阶段被发现,平均耗时3.25小时,如果漏到系统测试阶段,要花费11.5小时。

这张图,旨在说明两个问题:85%的缺陷都在代码设计阶段产生,而发现Bug的阶段越靠后,耗费成本就越高,指数级别的增高。

尽管单元测试有如此的收益,但在我们日常的工作中,仍然存在不少项目它们的单元测试要么是不完整要么是缺失的。常见的原因总结如下:代码逻辑过于复杂;写单元测试时耗费的时间较长;任务重、工期紧,或者干脆就不写了。

基于以上问题,相较于传统的JUnit单元测试,今天为大家推荐一款名为Spock的测试框架。目前,美团优选物流技术团队绝大部分后端服务已经采用了Spock作为测试框架,在开发效率、可读性和维护性方面取得了不错的收益。

不过网上Spock资料比较简单,甚至包括官网的Demo,无法解决我们项目中复杂业务场景面临的问题,通过深入学习和实践之后,本文会将一些经验分享出来,希望能够帮助大家提高开发测试的效率。

2. Spock是什么?和JUnit、jMock有什么区别?

Spock是一款国外优秀的测试框架,基于BDD(行为驱动开发)思想实现,功能非常强大。Spock结合动态语言的特点,提供了各种标签,并采用简单、通用、结构化的描述语言,让编写测试代码更加简洁、高效。官方的介绍如下:

What is it? Spock is a and for Java and . What makes it stand out from the crowd is its and . to its JUnit , Spock is with most IDEs, build tools, and . Spock is from JUnit, RSpec, jMock, , , Scala, , and other life forms.

Spock是一个Java和应用的测试和规范框架。之所以能够在众多测试框架中脱颖而出,是因为它优美而富有表现力的规范语言。Spock的灵感来自JUnit、RSpec、jMock、、、Scala、。

简单来讲,Spock主要特点如下:

为什么使用Spock? Spock和JUnit、jMock、的区别在哪里?

总的来说,JUnit、jMock、都是相对独立的工具,只是针对不同的业务场景提供特定的解决方案。其中JUnit单纯用于测试,并不提供Mock功能。

我们的服务大部分是分布式微服务架构。服务与服务之间通常都是通过接口的方式进行交互。即使在同一个服务内也会分为多个模块,业务功能需要依赖下游接口的返回数据,才能继续后面的处理流程。这里的下游不限于接口,还包括中间件数据存储比如、DB、MCC配置中心等等,所以如果想要测试自己的代码逻辑,就必须把这些依赖项Mock掉。因为如果下游接口不稳定可能会影响我们代码的测试结果,让下游接口返回指定的结果集(事先准备好的数据),这样才能验证我们的代码是否正确,是否符合逻辑结果的预期。

尽管jMock、提供了Mock功能,可以把接口等依赖屏蔽掉,但不能对静态方法Mock。虽然、能够提供静态方法的Mock,但它们之间也需要配合(JUnit + )使用,并且语法上比较繁琐。工具多了就会导致不同的人写出的单元测试代码“五花八门”,风格相差较大。

Spock通过提供规范性的描述,定义多种标签(given、when、then、where等),去描述代码“应该做什么”,“输入条件是什么”,“输出是否符合预期”,从语义层面规范了代码的编写。

Spock自带Mock功能,使用简单方便(也支持扩展其他Mock框架,比如),再加上动态语言的强大语法,能写出简洁高效的测试代码,同时能方便直观地验证业务代码的行为流转,增强工程师对代码执行逻辑的可控性。

3. 使用Spock解决单元测试开发中的痛点

如果在(if/else)分支很多的复杂场景下,编写单元测试代码的成本会变得非常高,正常的业务代码可能只有几十行,但为了测试这个功能覆盖大部分的分支场景,编写的测试代码可能远不止几十行。

之前有遇到过某个功能上线很久一直都很正常,没有出现过问题,但后来有个调用请求的数据不一样,走到了代码中一个不常用的逻辑分支时,出现了Bug。当时写这段代码的同学也认为只有很小几率才能走到这个分支,尽管当时写了单元测试,但因为时间比较紧张,分支又多,就漏掉了这个分支的测试。

尽管使用JUnit的@参数化注解或者方式可以解决多数据分支问题,但不够直观,而且如果其中某一次分支测试Case出错了,它的报错信息也不够详尽。

这就需要一种编写测试用例高效、可读性强、占用工时少、维护成本低的测试框架。首先不能让业务人员排斥编写单元测试,更不能让工程师觉得写单元测试是在浪费时间。而且使用JUnit做测试工作量不算小。据初步统计,采用JUnit的话,它的测试代码行和业务代码行能到3:1。如果采用Spock作为测试框架的话,它的比例可缩减到1: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();}

能够看到上面的代码中有大量的if-else语句,Spock提供了where标签,可以让我们通过表格的方式来测试多种分支。

@Unroll
def "个税计算,收入:#income, 个税:#result"() {expect: "when + then 的组合"CalculateTaxUtils.calc(income) == resultwhere: "表格方式测试不同的分支逻辑"income || result-1     || 00      || 02999   || 89.973000   || 90.03001   || 90.111999  || 989.912000  || 990.012001  || 990.224999  || 3589.825000  || 3590.025001  || 3590.2534999  || 6089.7535000  || 6090.035001  || 6090.354999  || 12089.755000  || 1209055001  || 12090.3579999  || 20839.6580000  || 20840.080001  || 20840.45
}

上图中左边使用Spock写的单元测试代码,语法简洁,表格方式测试覆盖分支场景更加直观,开发效率高,更适合敏捷开发。

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

我们微服务场景很多时候需要依赖其他接口返回的结果,才能验证自己的代码逻辑。Mock工具是必不可少的。但jMock、的语法比较繁琐,再加上单元测试代码不像业务代码那么直观,又不能完全按照业务流程的思路写单元测试,这就让不少同学对单元测试代码可读性不够重视,最终导致测试代码难以阅读,维护起来更是难上加难。甚至很多同学自己写的单元测试,过几天再看也一样觉得“云里雾里”的。也有改了原来的代码逻辑导致单元测试执行失败的;或者新增了分支逻辑,单元测试没有覆盖到的;最终随着业务的快速迭代单元测试代码越来越难以维护。

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

Spock自带Mock功能,使用上简单方便(Spock也支持扩展第三方Mock框架,比如)。我们可以再看一个样例,对于如下的代码逻辑进行单元测试:

public StudentVO getStudentById(int id) {List 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;}

比较明显,左边的JUnit单元测试代码冗余,缺少结构层次,可读性差,随着后续的迭代,势必会导致代码的堆积,维护成本会变得越来越高。右边的单元测试代码Spock会强制要求使用given、when、then这样的语义标签(至少一个),否则编译不通过,这样就能保证代码更加规范,结构模块化,边界范围清晰,可读性强,便于扩展和维护。而且使用了自然语言描述测试步骤,让非技术人员也能看懂测试代码(given表示输入条件,when触发动作,then验证输出结果)。

Spock自带的Mock语法也非常简单:dao.() >> [, ]。

两个右箭头>>表示模拟接口的返回结果,再加上使用的语言,可以直接使用[]中括号表示返回的是List类型。

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

在项目初期阶段,可能为了追赶进度而没有时间写单元测试,或者这个时期写的单元测试只是为了达到覆盖率的要求(比如为了满足新增代码行或者分支覆盖率统计要求)。

很多工程师写的单元测试基本都是采用Java这种强类型语言编写,各种底层接口的Mock写起来不仅繁琐而且耗时。这时的单元测试代码可能就写得比较粗糙,有粒度过大的,也有缺少单元测试结果验证的。这样的单元测试对代码的质量帮助不大,更多是为了测试而测试。最后时间没少花,可效果却没有达到。

针对有效测试用例方面,我们测试基础组件组开发了一些检测工具(作为抓手),比如去扫描大家写的单元测试,检测单元测试的断言有效性等。另外在结果校验方面,Spock表现也是十分优异的。我们可以来看接下来的场景:void方法,没有返回结果,如何写测试这段代码的逻辑是否正确?

如何确保单元测试代码是否执行到了for循环里面的语句,循环里面的打折计算又是否正确呢?

  public void calculatePrice(OrderVO order){BigDecimal amount = BigDecimal.ZERO;for (SkuVO sku : order.getSkus()) {Integer skuId = sku.getSkuId();BigDecimal skuPrice = sku.getSkuPrice();BigDecimal discount = BigDecimal.valueOf(discountDao.getDiscount(skuId));BigDecimal price = skuPrice * discount;amount = amount.add(price);}order.setAmount(amount.setScale(2, BigDecimal.ROUND_HALF_DOWN));}

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

这里,2 * .(_) >> 0.95 >> 0.8在for循环中一共调用了2次,第一次返回结果0.95,第二次返回结果0.8,最后再进行验证,类似于JUnit中的断言。

这样的收益还是比较明显的,不仅提高了单元测试的可控性,而且方便验证业务代码的逻辑正确性和合理性,这也是BDD思想的一种体现。

4. Mock模拟

考虑如下场景,代码如下:

@Service
public class StudentService {@Autowiredprivate StudentDao studentDao;public StudentVO getStudentById(int id) {List 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;}
}

其中是使用注入的实例对象,我们只有拿到了返回的,才能继续下面的逻辑(根据id筛选学生,DTO和VO转换,邮编等)。所以正常的做法是把的()方法Mock掉,模拟一个指定的值,因为我们真正关心的是拿到后自己代码的逻辑,这是需要重点验证的地方。按照上面的思路使用Spock编写的测试代码如下:

class StudentServiceSpec extends Specification {def studentDao = Mock(StudentDao)def tester = new StudentService(studentDao: studentDao)def "test getStudentById"() {given: "设置请求参数"def student1 = new StudentDTO(id: 1, name: "张三", province: "北京")def student2 = new StudentDTO(id: 2, name: "李四", province: "上海")and: "mock studentDao返回值"studentDao.getStudentInfo() >> [student1, student2]when: "获取学生信息"def response = tester.getStudentById(1)then: "结果验证"with(response) {id == 1abbreviation == "京"postCode == "100000"}}
}

这里主要讲解Spock的代码(从上往下)。

def = Mock()这一行代码使用Spock自带的Mock方法,构造一个的Mock对象,如果要模拟方法的返回,只需.方法名() >> "模拟值"的方式,两个右箭头的方式即可。test 方法是单元测试的主要方法,可以看到分为4个模块:given、and、when、then,用来区分不同单元测试代码的作用:

每个标签后面的双引号里可以添加描述,说明这块代码的作用(非强制),如when:"获取信息"。因为Spock使用作为单元测试开发语言,所以代码量上比使用Java写的会少很多,比如given模块里通过构造函数的方式创建请求对象。

实际上.java这个类并没有3个参数的构造方法,是帮我们实现的。默认会提供一个包含所有对象属性的构造方法。而且调用方式上可以指定属性名,类似于key:value的语法,非常人性化,方便在属性多的情况下构造对象,如果使用Java写,可能就要调用很多的()方法,才能完成对象初始化的工作。

这个就是Spock的Mock用法,当调用.()方法时返回一个List。List的创建也很简单,中括号[]即表示List,会根据方法的返回类型,自动匹配是数组还是List,而List里的对象就是之前given块里构造的user对象,其中>>就是指定返回结果,类似的when().()语法,但更简洁一些。

如果要指定返回多个值的话,可以使用3个右箭头>>>,比如:.() >>> [[,],[,],[,]]。

也可以写成这样:.() >> [,] >> [,] >> [,]。

每次调用.()方法返回不同的值。

public List getStudentInfo(String id){List students = new ArrayList<>();return students;
}

这个( id)方法,有个参数id,这种情况下如果使用Spock的Mock模拟调用的话,可以使用下划线_匹配参数,表示任何类型的参数,多个逗号隔开,类似于的any()方法。如果类中存在多个同名方法,可以通过_ as参数类型的方式区别调用,如下面的语法:

// _ 表示匹配任意类型参数
List students = studentDao.getStudentInfo(_);// 如果有同名的方法,使用as指定参数类型区分
List students = studentDao.getStudentInfo(_ as String);

when模块里是真正调用要测试方法的入口.()。then模块作用是验证被测方法的结果是否正确,符合预期值,所以这个模块里的语句必须是表达式,类似于JUnit的断言机制,但不必显示地写,这也是一种约定优于配置的思想。then块中使用了Spock的with功能,可以验证返回结果对象内部的多个属性是否符合预期值,这个相对于JUnit的或的方式更简单一些。

强大的Where

上面的业务代码有2个if判断,是对邮编处理逻辑:

  // 邮编if ("上海".equals(studentDTO.getProvince())) {studentVO.setAbbreviation("沪");studentVO.setPostCode("200000");}if ("北京".equals(studentDTO.getProvince())) {studentVO.setAbbreviation("京");studentVO.setPostCode("100000");}

如果要完全覆盖这2个分支就需要构造不同的请求参数,多次调用被测试方法才能走到不同的分支。在前面,我们介绍了Spock的where标签可以很方便的实现这种功能,代码如下所示:

   @Unrolldef "input 学生id:#id, 返回的邮编:#postCodeResult, 返回的省份简称:#abbreviationResult"() {given: "Mock返回的学生信息"studentDao.getStudentInfo() >> studentswhen: "获取学生信息"def response = tester.getStudentById(id)then: "验证返回结果"with(response) {postCode == postCodeResultabbreviation == abbreviationResult}where: "经典之处:表格方式验证学生信息的分支场景"id | students                    || postCodeResult | abbreviationResult1  | getStudent(1, "张三", "北京") || "100000"       | "京"2  | getStudent(2, "李四", "上海") || "200000"       | "沪"}def getStudent(def id, def name, def province) {return [new StudentDTO(id: id, name: name, province: province)]}

where模块第一行代码是表格的列名,多个列使用|单竖线隔开,||双竖线区分输入和输出变量,即左边是输入值,右边是输出值。格式如下:

输入参数1 | 输入参数2 || 输出结果1 | 输出结果2

而且 IDEA支持格式化快捷键,因为表格列的长度不一样,手动对齐比较麻烦。表格的每一行代表一个测试用例,即被测方法执行了2次,每次的输入和输出都不一样,刚好可以覆盖全部分支情况。比如id、都是输入条件,其中对象的构造调用了方法,每次测试业务代码传入不同的值,、表示对返回的对象的属性判断是否正确。第一行数据的作用是验证返回的邮编是否是,第二行是验证邮编是否是。这个就是where+with的用法,更符合我们实际测试的场景,既能覆盖多种分支,又可以对复杂对象的属性进行验证,其中在定义的测试方法名,使用了的字面值特性:

即把请求参数值和返回结果值的字符串动态替换掉,#id、#、##号后面的变量是在方法内部定义的,实现占位符的功能。

@注解,可以把每一次调用作为一个单独的测试用例运行,这样运行后的单元测试结果更加直观:

而且如果其中某行测试结果不对,Spock的错误提示信息也很详细,方便进行排查(比如我们把第1条测试用例返回的邮编改成):

可以看出,第1条测试用例失败,错误信息是的预期结果和实际结果不符,业务代码逻辑返回的邮编是,而我们预期的邮编是,这样就可以排查是业务代码逻辑有问题,还是我们的断言不对。

5. 异常测试

我们再看下异常方面的测试,例如下面的代码:

 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");}}

是封装的业务异常,主要包含code、属性:

/*** 自定义业务异常*/
public class BusinessException extends RuntimeException {private String code;private String message;setXxx...getXxx...
}

这个大家应该都很熟悉,针对这种抛出多个不同错误码和错误信息的异常。如果使用JUnit的方式测试,会比较麻烦。如果是单个异常还好,如果是多个的话,测试代码就不太好写。

    @Testpublic void testException() {StudentVO student = null;try {service.validateStudent(student);} catch (BusinessException e) {Assert.assertEquals(e.getCode(), "10001");Assert.assertEquals(e.getMessage(), "student is null");}student = new StudentVO();try {service.validateStudent(student);} catch (BusinessException e) {Assert.assertEquals(e.getCode(), "10002");Assert.assertEquals(e.getMessage(), "student name is null");}}

当然可以使用JUnit的方式:

@Rule
public ExpectedException exception = ExpectedException.none();
exception.expect(BusinessException.class); // 验证异常类型
exception.expectMessage("xxxxxx"); //验证异常信息

或者使用@Test( = .class)注解,但这两种方式都有缺陷。

@Test方式不能指定断言的异常属性,比如code、。的方式也只提供了的API,对自定义的code不支持,尤其像上面的有很多分支抛出多种不同异常码的情况。接下来我们看下Spock是如何解决的。Spock内置()方法,可以捕获调用业务代码抛出的预期异常并验证,再结合where表格的功能,可以很方便地覆盖多种自定义业务异常,代码如下:

    @Unrolldef "validate student info: #expectedMessage"() {when: "校验"tester.validateStudent(student)then: "验证"def exception = thrown(expectedException)exception.code == expectedCodeexception.message == expectedMessagewhere: "测试数据"student           || expectedException | expectedCode | expectedMessagegetStudent(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 = nullbreakcase 10002:student = new StudentVO()breakcase 10003:condition1()breakcase 10004:condition1()condition2()breakcase 10005:condition1()condition2()condition3()break}return student}

在then标签里用到了Spock的()方法,这个方法可以捕获我们要测试的业务代码里抛出的异常。()方法的入参,是我们自己定义的异常变量,这个变量放在where标签里就可以实现验证多种异常情况的功能( Idea格式化快捷键,可以自动对齐表格)。类型调用方法里定义的异常,可以验证它所有的属性,code、是否符合预期值。

_美团优选架构_美团优选模式分析

6. Spock静态方法测试

接下来,我们一起看下Spock如何扩展第三方对静态方法进行测试。

Spock的单元测试代码继承自基类,而又是基于JUnit的注解@()实现的,代码如下:

的也是继承自JUnit,所以使用的@ate()注解,可以指定Spock的父类去代理运行,这样就可以在Spock里使用去模拟静态方法、final方法、私有方法等。其实Spock自带的可以对文件的静态方法Mock,但对Java代码支持不完整,只能Mock当前Java类的静态方法,官方给出的解释如下:

如下代码:

 public StudentVO getStudentByIdStatic(int id) {List 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());// 静态方法调用String abbreviation = AbbreviationProvinceUtil.convert2Abbreviation(studentDTO.getProvince());studentVO.setAbbreviation(abbreviation);studentVO.setPostCode(studentDTO.getPostCode());return studentVO;}

上面使用了Util.()静态方法,对应的测试用例代码如下:

@RunWith(PowerMockRunner.class)
@PowerMockRunnerDelegate(Sputnik.class)
@PrepareForTest([AbbreviationProvinceUtil.class])
@SuppressStaticInitializationFor(["example.com.AbbreviationProvinceUtil"])
class StudentServiceStaticSpec extends Specification {def studentDao = Mock(StudentDao)def tester = new StudentService(studentDao: studentDao)void setup() {// mock静态类PowerMockito.mockStatic(AbbreviationProvinceUtil.class)}def "test getStudentByIdStatic"() {given: "创建对象"def student1 = new StudentDTO(id: 1, name: "张三", province: "北京")def student2 = new StudentDTO(id: 2, name: "李四", province: "上海")and: "Mock掉接口返回的学生信息"studentDao.getStudentInfo() >> [student1, student2]and: "Mock静态方法返回值"PowerMockito.when(AbbreviationProvinceUtil.convert2Abbreviation(Mockito.any())).thenReturn(abbreviationResult)when: "调用获取学生信息方法"def response = tester.getStudentByIdStatic(id)then: "验证返回结果是否符合预期值"with(response) {abbreviation == abbreviationResult}where:id || abbreviationResult1  || "京"2  || "沪"}
}

在Spec类的头部使用@ate(.class)注解,交给Spock代理执行,这样既可以使用Spock + 的各种功能,又可以使用的对静态,final等方法的Mock。@([".Util"]),这行代码的作用是限制Util类里的静态代码块初始化,因为Util类在第一次调用时可能会加载一些本地资源配置,所以可以使用禁止初始化。

然后在setup()方法里对静态类进行Mock设置,.(Util.class)。最后在test 测试方法里对()方法指定返回默认值:.when(Util.(.any())).()。

运行时在控制台会输出:

are not for

这是的警告信息,不影响运行结果。

如果单元测试代码不需要对静态方法、final方法Mock,就没必要使用,使用Spock自带的Mock()就足够了。因为的原理是在编译期通过ASM字节码修改工具修改代码,然后使用自己的加载,而加载的静态方法越多,测试耗时就会越长。

7. 动态Mock静态方法

考虑场景,让静态方法每次调用返回不同的值。

以下代码:

public List getOrdersBySource(){List orderList = new ArrayList<>();OrderVO order = new OrderVO();if ("APP".equals(HttpContextUtils.getCurrentSource())) {if("CNY".equals(HttpContextUtils.getCurrentCurrency())){System.out.println("source -> APP, currency -> CNY");} else {System.out.println("source -> APP, currency -> !CNY");}order.setType(1);} else if ("WAP".equals(HttpContextUtils.getCurrentSource())) {System.out.println("source -> WAP");order.setType(2);} else if ("ONLINE".equals(HttpContextUtils.getCurrentSource())) {System.out.println("source -> ONLINE");order.setType(3);}orderList.add(order);return orderList;
}

这段代码的if else分支逻辑,主要是依据这个工具类的静态方法()和()的返回值来决定流程。这样的业务代码也是我们平时写单元测试时经常遇到的场景,如果能让.()静态方法每次Mock出不同的值,就可以很方便地覆盖if else的全部分支逻辑。Spock的where标签可以方便地和结合使用,让模拟的静态方法每次返回不同的值,代码如下:

的方法返回的值是和等2个变量,不是具体的数据,这2个变量对应where标签里的前两列|。这样的写法,就可以在每次测试业务方法时,让.()和.()返回不同的来源和币种,就能轻松的覆盖if和else的分支代码。即Spock使用where表格的方式让具有了动态Mock的功能。接下来,我们再看一下如何对于final变量进行Mock。

public List convertOrders(List orders){List orderList = new ArrayList<>();for (OrderDTO orderDTO : orders) {OrderVO orderVO = OrderMapper.INSTANCE.convert(orderDTO);if (1 == orderVO.getType()) {orderVO.setOrderDesc("App端订单");} else if(2 == orderVO.getType()) {orderVO.setOrderDesc("H5端订单");} else if(3 == orderVO.getType()) {orderVO.setOrderDesc("PC端订单");}orderList.add(orderVO);}return orderList;
}

这段代码里的for循环第一行调用了..()转换方法,将转换为,然后根据type值走不同的分支,而是一个接口,代码如下:

@Mapper
public interface OrderMapper {// 即使不用static final修饰,接口里的变量默认也是静态、final的static final OrderMapper INSTANCE = Mappers.getMapper(OrderMapper.class);@Mappings({})OrderVO convert(OrderDTO requestDTO);
}

是接口里定义的变量,接口里的变量默认都是 final的,所以我们要先把这个静态final变量Mock掉,这样才能调用它的方法()返回我们想要的值。这个接口是工具的用法,是做对象属性映射的一个工具,它会自动生成接口的实现类,生成对应的set、get方法,把的属性值赋给属性,通常情况下会比使用反射的方式好不少。看下Spock如何写这个单元测试:

@Unroll
def "test convertOrders"() {given: "Mock掉OrderMapper的静态final变量INSTANCE,并结合Spock设置动态返回值"def orderMapper = Mock(OrderMapper.class)Whitebox.setInternalState(OrderMapper.class, "INSTANCE", orderMapper)orderMapper.convert(_) >> orderwhen: def orders = service.convertOrders([new OrderDTO()])then: "验证结果"with(orders) {it[0].orderDesc == desc}where: "测试数据"order                || descnew OrderVO(type: 1) || "App端订单"new OrderVO(type: 2) || "H5端订单"new OrderVO(type: 3) || "PC端订单"
}

主要是这3行代码:

def orderMapper = Mock(OrderMapper.class) // 先使用Spock的Mock
Whitebox.setInternalState(OrderMapper.class, "INSTANCE", orderMapper) // 通过PowerMock把Mock对象orderMapper赋值给静态常量INSTANCE
orderMapper.convert(_) >> order // 结合where模拟不同的返回值

这样就可以使用Spock结合测试静态常量,达到覆盖if else不同分支逻辑的功能。

8. 覆盖率

是统计单元测试覆盖率的一种工具,当然Spock也自带了覆盖率统计的功能,这里使用第三方的原因主要是国内公司使用的比较多一些,包括美团很多技术团队现在使用的也是,所以为了兼容就以来查看单元测试覆盖率。这里说下如何通过确认分支是否完全覆盖到。

在pom文件里引用的插件:-maven-,然后执行mvn 命令,成功后会在目录下生成单元测试覆盖率的报告,点开报告找到对应的被测试类查看覆盖情况。

绿色背景表示完全覆盖,黄色是部分覆盖,红色没有覆盖到。比如第34行黄色背景的else if()判断,提示有二分之一的分支缺失,虽然它下面的代码也被覆盖了(显示为绿色),这种情况跟具体使用哪种单元测试框架没关系,因为这只是分支覆盖率统计的规则,只不过使用Spock的话,解决起来会更简单,只需在where下增加一行针对的测试数据即可。

9. DAO层测试

DAO层的测试有些不太一样,不能再使用Mock,否则无法验证SQL是否正确。对于DAO测试有一般最简的方式是直接使用@注解启动测试环境,通过创建、实例,但这种方式并不属于单元测试,而是集成测试范畴了,因为当启用@时,会把整个应用的上下文加载进来。不仅耗时时间长,而且一旦依赖环境上有任何问题,可能会影响启动,进而影响DAO层的测试。最后,需要到数据库尽可能隔离,因为如果大家都使用同一个Test环境的数据的话,一旦测试用例编写有问题,就可能会污染Test环境的数据。

针对以上场景,可采用以下方案:

通过的启动实例(避免通过启动加载上下文信息)。

通过内存数据库(如H2)隔离大家的数据库连接(完全隔离不会存在互相干扰的现象)。

通过工具,用作对于数据库层的操作访问工具。

通过扩展Spock的注解,提供对于数据库创建和数据Data加载的方式。如csv、xml或直接编写等。

在pom文件增加相应的依赖。

com.h2databaseh21.4.200testorg.dbunitdbunit2.5.1test

增加的maven插件、资源文件拷贝以及测试覆盖率统计插件。


org.codehaus.gmavenplusgmavenplus-plugin1.8.1addSourcesaddTestSourcesgenerateStubscompilegenerateTestStubscompileTestsremoveStubsremoveTestStubs

org.apache.maven.pluginsmaven-surefire-plugin3.0.0-M3false**/*Spec.javamethods10true

org.apache.maven.pluginsmaven-resources-plugin2.6copy-resourcescompilecopy-resources${basedir}/target/resources${basedir}/src/main/resourcestrue

org.jacocojacoco-maven-plugin0.8.2prepare-agentprepare-agentreportprepare-packagereportpost-unit-testtestreporttarget/jacoco.exectarget/jacoco-ut

加入对于Spock扩展的自动处理框架(用于数据和Data初始化操作)。

这里介绍一下主要内容,注解@:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@ExtensionAnnotation(MyDbUnitExtension.class)
@interface MyDbUnit {/*** 
* content = {*    your_table_name(id: 1, name: 'xxx', age: 21)*    your_table_name(id: 2, name: 'xxx', age: 22)* })

*@*/Class

关于我们

最火推荐

小编推荐

联系我们


版权声明:本站内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 88@qq.com 举报,一经查实,本站将立刻删除。备案号:桂ICP备2021009421号
Powered By Z-BlogPHP.
复制成功
微信号:
我知道了