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

1. 背景

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

  • 它是最容易保障代码覆盖率达到100%的测试。
  • 能够⼤幅升高上线时的缓和指数。
  • 单元测试能更快地发现问题(见下图左)。
  • 单元测试的性价比最高,因为谬误发现的越晚,修复它的老本就越高,而且难度呈指数式增长,所以咱们要尽早地进行测试(见下图右)。
  • 编码人员,个别也是单元测试的次要执行者,是惟一可能做到生产出无缺点程序的人,其余任何人都无奈做到这一点。
  • 有助于源码的优化,使之更加标准,疾速反馈,能够释怀进行重构。
这张图来自微软的统计数据:Bug在单元测试阶段被发现,均匀耗时3.25小时,如果漏到零碎测试阶段,要花费11.5小时。这张图,旨在阐明两个问题:85%的缺点都在代码设计阶段产生,而发现Bug的阶段越靠后,消耗老本就越高,指数级别的增高。

只管单元测试有如此的收益,但在咱们日常的工作中,依然存在不少我的项目它们的单元测试要么是不残缺要么是缺失的。常见的起因总结如下:代码逻辑过于简单;写单元测试时消耗的工夫较长;工作重、工期紧,或者罗唆就不写了。

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

不过网上Spock材料比较简单,甚至包含官网的Demo,无奈解决咱们我的项目中简单业务场景面临的问题,通过深刻学习和实际之后,本文会将一些教训分享进去,心愿可能帮忙大家进步开发测试的效率。

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

Spock是一款国外优良的测试框架,基于BDD(行为驱动开发)思维实现,性能十分弱小。Spock联合Groovy动静语言的特点,提供了各种标签,并采纳简略、通用、结构化的描述语言,让编写测试代码更加简洁、高效。官网的介绍如下:

What is it?
Spock is a testing and specification framework for Java and Groovy applications. What makes it stand out from the crowd is its beautiful and highly expressive specification language. Thanks to its JUnit runner, Spock is compatible with most IDEs, build tools, and continuous integration servers. Spock is inspired from JUnit, RSpec, jMock, Mockito, Groovy, Scala, Vulcans, and other fascinating life forms.

Spock是一个Java和Groovy`利用的测试和标准框架。之所以可能在泛滥测试框架中怀才不遇,是因为它柔美而富裕表现力的标准语言。Spock的灵感来自JUnit、RSpec、jMock、Mockito、Groovy、Scala、Vulcans。

简略来讲,Spock次要特点如下:

  • 让测试代码更标准,内置多种标签来标准单元测试代码的语义,测试代码构造清晰,更具可读性,升高前期保护难度。
  • 提供多种标签,比方:givenwhenthenexpectwherewiththrown......帮忙咱们应答简单的测试场景。
  • 应用Groovy这种动静语言来编写测试代码,能够让咱们编写的测试代码更简洁,适宜麻利开发,进步编写单元测试代码的效率。
  • 听从BDD(行为驱动开发)模式,有助于晋升代码的品质。
  • IDE兼容性好,自带Mock性能。

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

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

咱们的服务大部分是散布式微服务架构。服务与服务之间通常都是通过接口的形式进行交互。即便在同一个服务内也会分为多个模块,业务性能须要依赖上游接口的返回数据,能力持续前面的解决流程。这里的上游不限于接口,还包含中间件数据存储比方Squirrel、DB、MCC配置核心等等,所以如果想要测试本人的代码逻辑,就必须把这些依赖项Mock掉。因为如果上游接口不稳固可能会影响咱们代码的测试后果,让上游接口返回指定的后果集(当时筹备好的数据),这样能力验证咱们的代码是否正确,是否合乎逻辑后果的预期。

只管jMock、Mockito提供了Mock性能,能够把接口等依赖屏蔽掉,但不能对静态方法Mock。尽管PowerMock、jMockit可能提供静态方法的Mock,但它们之间也须要配合(JUnit + Mockito PowerMock)应用,并且语法上比拟繁琐。工具多了就会导致不同的人写出的单元测试代码“形形色色”,格调相差较大。

Spock通过提供规范性的形容,定义多种标签(givenwhenthenwhere等),去形容代码“应该做什么”,“输出条件是什么”,“输入是否合乎预期”,从语义层面标准了代码的编写。

Spock自带Mock性能,应用简略不便(也反对扩大其余Mock框架,比方PowerMock),再加上Groovy动静语言的弱小语法,能写出简洁高效的测试代码,同时能不便直观地验证业务代码的行为流转,加强工程师对代码执行逻辑的可控性。

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

如果在(if/else)分支很多的简单场景下,编写单元测试代码的老本会变得十分高,失常的业务代码可能只有几十行,但为了测试这个性能笼罩大部分的分支场景,编写的测试代码可能远不止几十行。

之前有遇到过某个性能上线很久始终都很失常,没有呈现过问题,但起初有个调用申请的数据不一样,走到了代码中一个不罕用的逻辑分支时,呈现了Bug。过后写这段代码的同学也认为只有很小几率能力走到这个分支,只管过后写了单元测试,但因为工夫比拟缓和,分支又多,就漏掉了这个分支的测试。

只管应用JUnit的@Parametered参数化注解或者DataProvider形式能够解决多数据分支问题,但不够直观,而且如果其中某一次分支测试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标签,能够让咱们通过表格的形式来测试多种分支。

@Unrolldef "个税计算,支出:#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写的单元测试代码,语法简洁,表格形式测试笼罩分支场景更加直观,开发效率高,更适宜麻利开发。

单元测试代码的可读性和前期保护

咱们微服务场景很多时候须要依赖其余接口返回的后果,能力验证本人的代码逻辑。Mock工具是必不可少的。但jMock、Mockito的语法比拟繁琐,再加上单元测试代码不像业务代码那么直观,又不能齐全依照业务流程的思路写单元测试,这就让不少同学对单元测试代码可读性不够器重,最终导致测试代码难以浏览,保护起来更是难上加难。甚至很多同学本人写的单元测试,过几天再看也一样感觉“云里雾里”的。也有改了原来的代码逻辑导致单元测试执行失败的;或者新增了分支逻辑,单元测试没有笼罩到的;最终随着业务的疾速迭代单元测试代码越来越难以保护。

Spock提供多种语义标签,如:givenwhenthenexpectwherewithand等,从行为上标准了单元测试代码,每一种标签对应一种语义,让单元测试代码构造具备层次感,功能模块划分更加清晰,也便于前期的保护。

Spock自带Mock性能,应用上简略不便(Spock也反对扩大第三方Mock框架,比方PowerMock)。咱们能够再看一个样例,对于如下的代码逻辑进行单元测试:

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

比拟显著,右边的JUnit单元测试代码冗余,短少构造档次,可读性差,随着后续的迭代,势必会导致代码的沉积,保护老本会变得越来越高。左边的单元测试代码Spock会强制要求应用givenwhenthen这样的语义标签(至多一个),否则编译不通过,这样就能保障代码更加标准,构造模块化,边界范畴清晰,可读性强,便于扩大和保护。而且应用了自然语言形容测试步骤,让非技术人员也能看懂测试代码(given示意输出条件,when触发动作,then验证输入后果)。

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

两个右箭头>>示意模仿getStudentInfo接口的返回后果,再加上应用的Groovy语言,能够间接应用[]中括号示意返回的是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 * discountDao.getDiscount(_) >> 0.95 >> 0.8for循环中一共调用了2次,第一次返回后果0.95,第二次返回后果0.8,最初再进行验证,相似于JUnit中的Assert断言。

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

4. Mock模仿

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

@Servicepublic class StudentService {    @Autowired    private StudentDao studentDao;    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;    }}

其中studentDao是应用Spring注入的实例对象,咱们只有拿到了返回的students,能力持续上面的逻辑(依据id筛选学生,DTOVO转换,邮编等)。所以失常的做法是把studentDaogetStudentInfo()办法Mock掉,模仿一个指定的值,因为咱们真正关怀的是拿到students后本人代码的逻辑,这是须要重点验证的中央。依照下面的思路应用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 == 1            abbreviation == "京"            postCode == "100000"        }    }}

这里次要解说Spock的代码(从上往下)。

def studentDao = Mock(StudentDao) 这一行代码应用Spock自带的Mock办法,结构一个studentDao的Mock对象,如果要模仿studentDao办法的返回,只需studentDao.办法名() >> "模仿值"的形式,两个右箭头的形式即可。test getStudentById办法是单元测试的次要办法,能够看到分为4个模块:givenandwhenthen,用来辨别不同单元测试代码的作用:

  • given:输出条件(前置参数)。
  • when:执行行为(Mock接口、实在调用)。
  • then:输入条件(验证后果)。
  • and:连接上个标签,补充的作用。

每个标签前面的双引号里能够增加形容,阐明这块代码的作用(非强制),如when:"获取信息"。因为Spock应用Groovy作为单元测试开发语言,所以代码量上比应用Java写的会少很多,比方given模块里通过构造函数的形式创立申请对象。

实际上StudentDTO.java 这个类并没有3个参数的构造方法,是Groovy帮咱们实现的。Groovy默认会提供一个蕴含所有对象属性的构造方法。而且调用形式上能够指定属性名,相似于key:value的语法,十分人性化,不便在属性多的状况下结构对象,如果应用Java写,可能就要调用很多的setXxx()办法,能力实现对象初始化的工作。

这个就是Spock的Mock用法,当调用studentDao.getStudentInfo()办法时返回一个ListList的创立也很简略,中括号[]即示意List,Groovy会依据办法的返回类型,主动匹配是数组还是List,而List里的对象就是之前given块里结构的user对象,其中 >> 就是指定返回后果,相似Mockitowhen().thenReturn()语法,但更简洁一些。

如果要指定返回多个值的话,能够应用3个右箭头>>>,比方:studentDao.getStudentInfo() >>> [[student1,student2],[student3,student4],[student5,student6]]

也能够写成这样:studentDao.getStudentInfo() >> [student1,student2] >> [student3,student4] >> [student5,student6]

每次调用studentDao.getStudentInfo()办法返回不同的值。

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

这个getStudentInfo(String id)办法,有个参数id,这种状况下如果应用Spock的Mock模仿调用的话,能够应用下划线_匹配参数,示意任何类型的参数,多个逗号隔开,相似于Mockitoany()办法。如果类中存在多个同名办法,能够通过 _ as参数类型 的形式区别调用,如上面的语法:

// _ 示意匹配任意类型参数List<StudentDTO> students = studentDao.getStudentInfo(_);// 如果有同名的办法,应用as指定参数类型辨别List<StudentDTO> students = studentDao.getStudentInfo(_ as String);

when模块里是真正调用要测试方法的入口tester.getStudentById()then模块作用是验证被测办法的后果是否正确,合乎预期值,所以这个模块里的语句必须是boolean表达式,相似于JUnit的assert断言机制,但不用显示地写assert,这也是一种约定优于配置的思维。then块中应用了Spock的with性能,能够验证返回后果response对象外部的多个属性是否合乎预期值,这个绝对于JUnit的assertNotNullassertEquals的形式更简略一些。

弱小的Where

下面的业务代码有2个if判断,是对邮编解决逻辑:

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

如果要齐全笼罩这2个分支就须要结构不同的申请参数,屡次调用被测试方法能力走到不同的分支。在后面,咱们介绍了Spock的where标签能够很不便的实现这种性能,代码如下所示:

   @Unroll   def "input 学生id:#id, 返回的邮编:#postCodeResult, 返回的省份简称:#abbreviationResult"() {        given: "Mock返回的学生信息"        studentDao.getStudentInfo() >> students        when: "获取学生信息"        def response = tester.getStudentById(id)        then: "验证返回后果"        with(response) {            postCode == postCodeResult            abbreviation == abbreviationResult        }        where: "经典之处:表格形式验证学生信息的分支场景"        id | students                    || postCodeResult | abbreviationResult        1  | 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

而且IntelliJ IDEA反对format格式化快捷键,因为表格列的长度不一样,手动对齐比拟麻烦。表格的每一行代表一个测试用例,即被测办法执行了2次,每次的输出和输入都不一样,刚好能够笼罩全副分支状况。比方idstudents都是输出条件,其中students对象的结构调用了getStudent办法,每次测试业务代码传入不同的student值,postCodeResultabbreviationResult示意对返回的response对象的属性判断是否正确。第一行数据的作用是验证返回的邮编是否是100000,第二行是验证邮编是否是200000。这个就是where+with的用法,更合乎咱们理论测试的场景,既能笼罩多种分支,又能够对简单对象的属性进行验证,其中在定义的测试方法名,应用了Groovy的字面值个性:

即把申请参数值和返回后果值的字符串动静替换掉,#id#postCodeResult#abbreviationResult#号前面的变量是在办法外部定义的,实现占位符的性能。

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

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

能够看出,第1条测试用例失败,错误信息是postCodeResult的预期后果和理论后果不符,业务代码逻辑返回的邮编是100000,而咱们预期的邮编是100001,这样就能够排查是业务代码逻辑有问题,还是咱们的断言不对。

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

BusinessException是封装的业务异样,次要蕴含codemessage属性:

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

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

    @Test    public 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的ExpectedException形式:

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

或者应用@Test(expected = BusinessException.class) 注解,但这两种形式都有缺点。

@Test形式不能指定断言的异样属性,比方codemessageExpectedException的形式也只提供了expectMessage的API,对自定义的code不反对,尤其像下面的有很多分支抛出多种不同异样码的状况。接下来咱们看下Spock是如何解决的。Spock内置thrown()办法,能够捕捉调用业务代码抛出的预期异样并验证,再联合where表格的性能,能够很不便地笼罩多种自定义业务异样,代码如下:

    @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    }

then标签里用到了Spock的thrown()办法,这个办法能够捕捉咱们要测试的业务代码里抛出的异样。thrown()办法的入参expectedException,是咱们本人定义的异样变量,这个变量放在where标签里就能够实现验证多种异常情况的性能(Intellij Idea格式化快捷键,能够主动对齐表格)。expectedException类型调用validateUser办法里定义的BusinessException异样,能够验证它所有的属性,codemessage是否合乎预期值。

6. Spock静态方法测试

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

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

PowerMock的PowerMockRunner也是继承自JUnit,所以应用PowerMock的@PowerMockRunnerDelegate()注解,能够指定Spock的父类Sputnik去代理运行PowerMock,这样就能够在Spock里应用PowerMock去模仿静态方法、final办法、公有办法等。其实Spock自带的GroovyMock能够对Groovy文件的静态方法Mock,但对Java代码反对不残缺,只能Mock以后Java类的静态方法,官网给出的解释如下:

如下代码:

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

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

@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 || abbreviationResult        1  || "京"        2  || "沪"    }}

StudentServiceStaticSpec类的头部应用@PowerMockRunnerDelegate(Sputnik.class)注解,交给Spock代理执行,这样既能够应用Spock +Groovy的各种性能,又能够应用PowerMock的对动态,final等办法的Mock。@SuppressStaticInitializationFor(["example.com.AbbreviationProvinceUtil"]),这行代码的作用是限度AbbreviationProvinceUtil类里的动态代码块初始化,因为AbbreviationProvinceUtil类在第一次调用时可能会加载一些本地资源配置,所以能够应用PowerMock禁止初始化。而后在setup()办法里对动态类进行Mock设置,PowerMockito.mockStatic(AbbreviationProvinceUtil.class)。最初在test getStudentByIdStatic测试方法里对convert2Abbreviation()办法指定返回默认值:PowerMockito.when(AbbreviationProvinceUtil.convert2Abbreviation(Mockito.any())).thenReturn(abbreviationResult)

运行时在控制台会输入:

<font color="#dd0000">Notifications are not supported for behaviour ALL_TESTINSTANCES_ARE_CREATED_FIRST</font>

这是Powermock的正告信息,不影响运行后果。

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

7. 动静Mock静态方法

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

以下代码:

public List<OrderVO> getOrdersBySource(){        List<OrderVO> 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分支逻辑,次要是根据HttpContextUtils这个工具类的静态方法getCurrentSource()getCurrentCurrency()的返回值来决定流程。这样的业务代码也是咱们平时写单元测试时常常遇到的场景,如果能让HttpContextUtils.getCurrentSource()静态方法每次Mock出不同的值,就能够很不便地笼罩if else的全副分支逻辑。Spock的where标签能够不便地和PowerMock联合应用,让PowerMock模仿的静态方法每次返回不同的值,代码如下:

PowerMock的thenReturn办法返回的值是sourcecurrency等2个变量,不是具体的数据,这2个变量对应where标签里的前两列source|currency。这样的写法,就能够在每次测试业务办法时,让HttpContextUtils.getCurrentSource()HttpContextUtils.getCurrentCurrency()返回不同的起源和币种,就能轻松的笼罩ifelse的分支代码。即Spock应用where表格的形式让PowerMock具备了动静Mock的性能。接下来,咱们再看一下如何对于final变量进行Mock。

public List<OrderVO> convertOrders(List<OrderDTO> orders){        List<OrderVO> 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循环第一行调用了OrderMapper.INSTANCE.convert()转换方法,将orderDTO转换为orderVO,而后依据type值走不同的分支,而OrderMapper是一个接口,代码如下:

@Mapperpublic interface OrderMapper {    // 即便不必static final润饰,接口里的变量默认也是动态、final的    static final OrderMapper INSTANCE = Mappers.getMapper(OrderMapper.class);    @Mappings({})    OrderVO convert(OrderDTO requestDTO);}

INSTANCE是接口OrderMapper里定义的变量,接口里的变量默认都是static final的,所以咱们要先把这个INSTANCE动态final变量Mock掉,这样能力调用它的办法convert()返回咱们想要的值。OrderMapper这个接口是mapstruct工具的用法,mapstruct是做对象属性映射的一个工具,它会主动生成OrderMapper接口的实现类,生成对应的setget办法,把orderDTO的属性值赋给orderVO属性,通常状况下会比应用反射的形式好不少。看下Spock如何写这个单元测试:

@Unrolldef "test convertOrders"() {  given: "Mock掉OrderMapper的动态final变量INSTANCE,并联合Spock设置动静返回值"  def orderMapper = Mock(OrderMapper.class)  Whitebox.setInternalState(OrderMapper.class, "INSTANCE", orderMapper)  orderMapper.convert(_) >> order  when:   def orders = service.convertOrders([new OrderDTO()])  then: "验证后果"  with(orders) {    it[0].orderDesc == desc  }  where: "测试数据"  order                || desc  new OrderVO(type: 1) || "App端订单"  new OrderVO(type: 2) || "H5端订单"  new OrderVO(type: 3) || "PC端订单"}
  • 首先应用Spock自带的Mock()办法,将OrderMapper类Mock为一个模仿对象orderMapperdef orderMapper = Mock(OrderMapper.class)
  • 而后应用PowerMock的Whitebox.setInternalState(),对OrderMapper接口的static final常量INSTANCE赋值(Spock不反对动态常量的Mock),赋的值正是应用SpockMock的对象orderMapper
  • 应用Spock的Mock模仿convert()办法调用,orderMapper.convert(_) >> order,再联合where表格,实现动静Mock接口的性能。

次要是这3行代码:

def orderMapper = Mock(OrderMapper.class) // 先应用Spock的MockWhitebox.setInternalState(OrderMapper.class, "INSTANCE", orderMapper) // 通过PowerMock把Mock对象orderMapper赋值给动态常量INSTANCEorderMapper.convert(_) >> order // 联合where模仿不同的返回值

这样就能够应用Spock联合PowerMock测试动态常量,达到笼罩if else不同分支逻辑的性能。

8. 覆盖率

Jacoco是统计单元测试覆盖率的一种工具,当然Spock也自带了覆盖率统计的性能,这里应用第三方Jacoco的起因次要是国内公司应用的比拟多一些,包含美团很多技术团队当初应用的也是Jacoco,所以为了兼容就以Jacoco来查看单元测试覆盖率。这里说下如何通过Jacoco确认分支是否齐全笼罩到。

在pom文件里援用Jacoco的插件:jacoco-maven-plugin,而后执行mvn package 命令,胜利后会在target目录下生成单元测试覆盖率的报告,点开报告找到对应的被测试类查看笼罩状况。

绿色背景示意齐全笼罩,黄色是局部笼罩,红色没有笼罩到。比方第34行黄色背景的else if() 判断,提醒有二分之一的分支缺失,尽管它上面的代码也被笼罩了(显示为绿色),这种状况跟具体应用哪种单元测试框架没关系,因为这只是分支覆盖率统计的规定,只不过应用Spock的话,解决起来会更简略,只需在where下减少一行针对的测试数据即可。

9. DAO层测试

DAO层的测试有些不太一样,不能再应用Mock,否则无奈验证SQL是否正确。对于DAO测试有个别最简的形式是间接应用@SpringBootTest注解启动测试环境,通过Spring创立Mybatis、Mapper实例,但这种形式并不属于单元测试,而是集成测试领域了,因为当启用@SpringBootTest时,会把整个利用的上下文加载进来。不仅耗时工夫长,而且一旦依赖环境上有任何问题,可能会影响启动,进而影响DAO层的测试。最初,须要到数据库尽可能隔离,因为如果大家都应用同一个Test环境的数据的话,一旦测试用例编写有问题,就可能会净化Test环境的数据。

针对以上场景,可采纳以下计划:

  1. 通过MyBatis的SqlSession启动mapper实例(防止通过Spring启动加载上下文信息)。
  2. 通过内存数据库(如H2)隔离大家的数据库连贯(齐全隔离不会存在相互烦扰的景象)。
  3. 通过DBUnit工具,用作对于数据库层的操作拜访工具。
  4. 通过扩大Spock的注解,提供对于数据库Schema创立和数据Data加载的形式。如csv、xml或间接Closure编写等。

在pom文件减少相应的依赖。

<dependency>     <groupId>com.h2database</groupId>     <artifactId>h2</artifactId>     <version>1.4.200</version>     <scope>test</scope> </dependency> <dependency>     <groupId>org.dbunit</groupId>     <artifactId>dbunit</artifactId>     <version>2.5.1</version>     <scope>test</scope> </dependency>

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

<!-- 测试插件 --><plugin>  <groupId>org.codehaus.gmavenplus</groupId>  <artifactId>gmavenplus-plugin</artifactId>  <version>1.8.1</version>  <executions>    <execution>      <goals>        <goal>addSources</goal>        <goal>addTestSources</goal>        <goal>generateStubs</goal>        <goal>compile</goal>        <goal>generateTestStubs</goal>        <goal>compileTests</goal>        <goal>removeStubs</goal>        <goal>removeTestStubs</goal>      </goals>    </execution>  </executions></plugin><plugin>  <groupId>org.apache.maven.plugins</groupId>  <artifactId>maven-surefire-plugin</artifactId>  <version>3.0.0-M3</version>  <configuration>    <useFile>false</useFile>    <includes>      <include>**/*Spec.java</include>    </includes>    <parallel>methods</parallel>    <threadCount>10</threadCount>    <testFailureIgnore>true</testFailureIgnore>  </configuration></plugin><plugin>  <groupId>org.apache.maven.plugins</groupId>  <artifactId>maven-resources-plugin</artifactId>  <version>2.6</version>  <executions>    <execution>      <id>copy-resources</id>      <phase>compile</phase>      <goals>        <goal>copy-resources</goal>      </goals>      <configuration>        <outputDirectory>${basedir}/target/resources</outputDirectory>        <resources>          <resource>            <directory>${basedir}/src/main/resources</directory>            <filtering>true</filtering>          </resource>        </resources>      </configuration>    </execution>  </executions></plugin><plugin>  <groupId>org.jacoco</groupId>  <artifactId>jacoco-maven-plugin</artifactId>  <version>0.8.2</version>  <executions>    <execution>      <id>prepare-agent</id>      <goals>        <goal>prepare-agent</goal>      </goals>    </execution>    <execution>      <id>report</id>      <phase>prepare-package</phase>      <goals>        <goal>report</goal>      </goals>    </execution>    <execution>      <id>post-unit-test</id>      <phase>test</phase>      <goals>        <goal>report</goal>      </goals>      <configuration>        <dataFile>target/jacoco.exec</dataFile>        <outputDirectory>target/jacoco-ut</outputDirectory>      </configuration>    </execution>  </executions></plugin>

退出对于Spock扩大的主动解决框架(用于数据SchemaData初始化操作)。

这里介绍一下次要内容,注解@MyDbUnit

@Retention(RetentionPolicy.RUNTIME)@Target(ElementType.METHOD)@ExtensionAnnotation(MyDbUnitExtension.class)@interface MyDbUnit {    /**     * <pre>     * content = {     *    your_table_name(id: 1, name: 'xxx', age: 21)     *    your_table_name(id: 2, name: 'xxx', age: 22)     * })     </pre>     * @return     */    Class<? extends Closure> content() default Closure.class;    /**     * xml寄存门路(绝对于测试类)     * @return     */    String xmlLocation() default "";    /**     * csv寄存门路(绝对于测试类)     * @return     */    String csvLocation() default "";}

思考以下代码的测试:

@Repository("personInfoMapper")public interface PersonInfoMapper {    @Delete("delete from person_info where id=#{id}")    int deleteById(Long id);    @Select("select count(*) from person_info")    int count();    @Select("select * from user_info")    List<PersonInfoDO> selectAll();}

Demo1 (应用@MyDbUnitcontent指定导入数据内容,格局Closure)。

class Demo1Spec extends MyBaseSpec {    /**     * 间接获取待测试的mapper     */    def personInfoMapper = MapperUtil.getMapper(PersonInfoMapper.class)    /**     * 测试数据筹备,通常为sql表构造创立用的ddl,反对多个文件以逗号分隔。     */    def setup() {        executeSqlScriptFile("com/xxx/xxx/xxx/......../schema.sql")    }    /**     * 数据表革除,通常待drop的数据表     */    def cleanup() {        dropTables("person_info")    }    /**     * 间接结构数据库中的数据表,此办法实用于数据量较小的mapper sql测试     */    @MyDbUnit(            content = {                person_info(id: 1, name: "abc", age: 21)                person_info(id: 2, name: "bcd", age: 22)                person_info(id: 3, name: "cde", age: 23)            }    )    def "demo1_01"() {        when:        int beforeCount = personInfoMapper.count()        // groovy sql用于疾速执行sql,不仅能验证数据后果,也可向数据中增加数据。        def result = new Sql(dataSource).firstRow("select * from `person_info`")         int deleteCount = personInfoMapper.deleteById(1L)        int afterCount = personInfoMapper.count()        then:        beforeCount == 3        result.name == "abc"        deleteCount == 1        afterCount == 2    }    /**     * 间接结构数据库中的数据表,此办法实用于数据量较小的mapper sql测试     */    @MyDbUnit(content = {        person_info(id: 1, name: 'a', age: 21)    })    def "demo1_02"() {        when:        int beforeCount = personInfoMapper.count()        def result = new Sql(dataSource).firstRow("select * from `person_info`")        int deleteCount = personInfoMapper.deleteById(1L)        int afterCount = personInfoMapper.count()        then:        beforeCount == 1        result.name == "a"        deleteCount == 1        afterCount == 0    }}

setup()阶段,把数据库表中的Schema创立好,而后通过上面的@MyDbUnit注解的content属性,把数据导入到数据库中。person_info是表名,idnameage是数据。

通过MapperUtil.getMapper() 办法获取mapper实例。

当测试数据量较大时,能够编写相应的数据文件,通过@MyDbUnitxmlLocationcsvLocation加载文件(别离反对csv和xml格局)。

如通过csv加载文件,csvLocation指向csv文件所在文件夹。

 @MyDbUnit(csvLocation = "com/xxx/........./data01")    def "demo2_01"() {        when:        int beforeCount = personInfoMapper.count()        def result = new Sql(dataSource).firstRow("select * from `person_info`")        int deleteCount = personInfoMapper.deleteById(1L)        int afterCount = personInfoMapper.count()        then:        beforeCount == 3        result.name == "abc"        deleteCount == 1        afterCount == 2    }

通过xml加载文件,xmlLocation指向xml文件所在门路。

@MyDbUnit(xmlLocation = "com/xxxx/........./demo3_02.xml")    def "demo3_02"() {        when:        int beforeCount = personInfoMapper.count()        def result = new Sql(dataSource).firstRow("select * from `person_info`")        int deleteCount = personInfoMapper.deleteById(1L)        int afterCount = personInfoMapper.count()        then:        beforeCount == 1        result.name == "a"        deleteCount == 1        afterCount == 0    }

还能够不通过@MyDbUnit而应用API间接加载测试数据文件。

class Demo4Spec extends MyBaseSpec {    def personInfoMapper = MapperUtil.getMapper(PersonInfoMapper.class)    /**     * 数据表革除,通常待drop的数据表     */    def cleanup() {        dropTables("person_info")    }    def "demo4_01"() {        given:        executeSqlScriptFile("com/xxxx/.........../schema.sql")        IDataSet dataSet = MyDbUnitUtil.loadCsv("com/xxxx/.........../data01");        DatabaseOperation.CLEAN_INSERT.execute(MyIDatabaseConnection.getInstance().getConnection(), dataSet);        when:        int beforeCount = personInfoMapper.count()        def result = new Sql(dataSource).firstRow("select * from `person_info`")        int deleteCount = personInfoMapper.deleteById(1L)        int afterCount = personInfoMapper.count()        then:        beforeCount == 3        result.name == "abc"        deleteCount == 1        afterCount == 2    }    def "demo4_02"() {        given:        executeSqlScriptFile("com/xxxx/.........../schema.sq")        IDataSet dataSet = MyDbUnitUtil.loadXml("com/xxxx/.........../demo3_02.xml");        DatabaseOperation.CLEAN_INSERT.execute(MyIDatabaseConnection.getInstance().getConnection(), dataSet);        when:        int beforeCount = personInfoMapper.count()        def result = new Sql(dataSource).firstRow("select * from `person_info`")        int deleteCount = personInfoMapper.deleteById(1L)        int afterCount = personInfoMapper.count()        then:        beforeCount == 1        result.name == "a"        deleteCount == 1        afterCount == 0    }}

最初为大家梳理了一些文档,供大家参考。

  • Spock Framework Reference Documentation
  • 老K的Java博客

作者简介

建华,美团优选事业部工程师。

浏览美团技术团队更多技术文章合集

前端 | 算法 | 后端 | 数据 | 平安 | 运维 | iOS | Android | 测试

| 在公众号菜单栏对话框回复【2020年货】、【2019年货】、【2018年货】、【2017年货】等关键词,可查看美团技术团队历年技术文章合集。

| 本文系美团技术团队出品,著作权归属美团。欢送出于分享和交换等非商业目标转载或应用本文内容,敬请注明“内容转载自美团技术团队”。本文未经许可,不得进行商业性转载或者应用。任何商用行为,请发送邮件至tech@meituan.com申请受权。