原文出处:@蛋疼的axb
1.摘要
最近一段时间接触到了spock这个可以用于java和groovy项目的单元测试框架,写了一段时间单测之后认为这个框架不错,值得写一篇文章推广一下。
2.关于单元测试
很多人一谈到单元测试就会想到xUnit框架。对于一些java新人来说,会用jUnit就是会写单元测试,高级点的会捣鼓一下testng,然后就认为自己掌握了单元测试。
而实际上,很多人不怎么会写单元测试,甚至不知道单元测试究竟是干什么的。写单元测试要比写代码要难上许多,而这里说的难度跟框架没什么关系。
所以,在开始介绍spock之前,需要先抛开框架,谈谈单元测试本身的事情。在理解了单元测试之后才能更清楚spock框架是什么,以及它否能够更优雅的解决你的问题。
2.1.1.单元测试是什么
写代码免不了要做测试,测试有很多种,对于java来说,最初级的就是写个main函数运行一下看看结果,高级的可以用各种高大上的复杂的测试系统。每种测试都有它的关注点,比如测试功能是不是正确,或者运行状态稳不稳定,或者能承受多少负载压力,等等。
那么所谓的单元测试是什么?这里直接引用维基百科上的词条说明:
单元测试(又称为模块测试, Unit Testing)是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。
所以,我眼中的“合格的”单元测试需要满足几个条件:
- 测试的是一个代码单元内部的逻辑,而不是各模块之间的交互。
- 无依赖,不需要实际运行环境就可以测试代码。
- 运行效率高,可以随时执行。
2.1.2.单元测试的定位
了解了单元测试是什么之后,第二个问题就是:单元测试是用来做什么的?
很多人第一反应是“看看程序有没有问题”,或者“确保没有bug”。单元测试确实可以测试程序有没有问题,但是,从我个人编程的经验来看,大部分情况下只是使用单元测试来“看看程序有没有问题”的话,效率反而不如把程序运行起来直接查看结果。原因有两个:
- 单元测试要写额外的代码,而不写单元测试,直接运行程序也可以测试程序有没有问题。
- 即使通过了单元测试,程序在实际运行的时候仍然有可能出问题。
但是,很多时候直接启动程序测试会比较慢,所以一些同学为了解决这个问题,采用了一个折中的办法:只加载要测试的模块和它所有的依赖模块,比如在测试时只加载这个模块相关的spring的配置文件。这时所谓的单元测试实际上是用xUnit框架运行的集成测试,并没有体现“单元”的概念。
而关于“纯粹的单元测试”在介绍语言或者框架的书里很少被提起,反而是介绍重构或者敏捷开发的书里经常会看到各种各样的关于单元测试的介绍。
在这里我总结了一下几个比较常见的单元测试的几个典型场景:
- 开发前写单元测试,通过测试描述需求,由测试驱动开发。
- 在开发过程中及时得到反馈,提前发现问题。
- 应用于自动化构建或持续集成流程,对每次代码修改做回归测试。
- 作为重构的基础,验证重构是否可靠。
还有最重要的一点:编写单元测试的难易程度能够直接反应出代码的设计水平,能写出单元测试和写不出单元测试之间体现了编程能力上的巨大的鸿沟。无论是什么样的程序员,坚持编写一段时间的单元测试之后,都会明显感受到代码设计能力的巨大提升。
2.2.单元测试的痛点
对于新人来说,很容易在编写单元测试的时候遇到这几类问题:
2.2.1.单元测试的资料不够全
这里不够全是相对于“编码”来说的。介绍如何编码、如何使用某个框架的书茫茫多,但是与编码同样重要的介绍单元测试的书却不多,翻来覆去好的也不多,并且都有一定年头了。(如果有这方面的好的资料,请推荐给我,多谢)
很多关于编程的书籍中并没有深入介绍如何进行单元测试,或者仅仅介绍了最基础的assert、jUnit里怎么定义一个测试函数之类,就没有然后了,给人的感觉是这样:
2.2.2.单元测试难以理解和维护
测试代码不像普通的应用程序一样有着很明确的作为“值”的输入和输出。举个例子,假如一个普通的函数要做下面这件事情:
- 接收一个user对象作为参数
- 调用dao层的update方法更新用户属性
- 返回true/false结果
那么,只需要在函数中声明一个参数、做一次调用、返回一个布尔值就可以了。但如果要对这个函数做一个“纯粹的”单元测试,那么它的输入和输出会有很多情况,比如其中一个测试是这样:
- 假设调用dao层的update方法会返回true。
- 程序去调用service层的update方法。
- 验证一下service是不是也返回了true。
无论是用什么样的单元测试框架,最后写出来的单元测试代码量也比业务代码只多不少,我在写代码过程中的经验值是:要在不作弊的情况下维持比较高的单元测试覆盖率,要有三倍于业务代码的单测代码。
更多的代码量,加上单测代码并不像业务代码那样直观,还有对单测代码可读性不重视的坏习惯,导致最终呈现出来的单测代码难以阅读,要维护更是难上加难。
同时,大部分单元测试的框架都有很强的代码侵入性。要理解单元测试,首先得学习他用的那个单元测试框架,这无形中又增加了单元测试理解和维护的难度。
2.2.3.单元测试难以去除依赖
就像之前说的,如果要写一个纯粹的、无依赖的单元测试往往很困难,比如依赖了数据库、或者依赖了文件系统、再或者依赖了其它模块。
所以很多人在写单元测试时选择依赖一部分资源,比如在本机启动一个数据库。这类所谓的“单元测试”往往很流行,但是对于多人合作的项目,这类测试却经常容易造成混乱。
比如说要在本地读个文件,或者连接某个数据库,其他修改代码的人(或者持续集成系统中)并没有这些东西,所以测试也都没法通过。最后大部分这类测试代码的下场都是用不了、也舍不得删,只好被注释掉,扔在那里。
随着开源项目逐渐发展,对外部资源的依赖问题开始可以通过一些测试辅助工具解决,比如使用内存型数据库H2代替连接实际的测试数据库,不过能替代的资源类型始终有限。
而实际工作过程中,还有一类难以处理的依赖问题:代码依赖。比如一个对象的方法中调用了其它对象的方法,其它对象又调用了更多对象,最后形成了一个无比巨大的调用树。
很多比较旧的描述单元测试的书里写了一些传统的办法,这类方法基本上是先对耦合的部分做模拟,再对结果部分做断言。例如可以通过继承来自己做一个假的stub对象,最终用assert的方式验证正确性。但是这相当于对于每种假设都要做一个假的对象,而且对结果进行验证也比较复杂:比如我要验证“更新”操作是否真的调用了dao层,那么要自己在stub对象里对调用进行计数,验证时再对计数进行断言,非常繁琐。
后来出现了一些mock框架,比如java的JMockit、EasyMock,或者Mockito。利用这类框架可以相对比较轻松的通过mock方式去做假设和验证,相对于之前的方式有了质的飞跃,但是即使用上这类框架,遇到复杂的业务代码往往也无能为力。
而往往新人的代码质量往往不高,尤其是对代码的拆分和逻辑的抽象还处于懵懂阶段。要对这类代码写单测,即使是工作了3,4年的高级码农也是一个挑战,对新人来说几乎是不可能完成的任务。这也让很多新人有了“写单测很难”的感觉。
所以在这里需要强调一个观点,写单元测试的难易程度跟代码的质量关系最大,并且是决定性的。项目里无论用了哪个测试框架都不能解决代码本身难以测试的问题,所以如果你遇到的是“我的代码里依赖的东西太多了所以写不出来单测”这样的问题的话,需要去看的是如何设计和重构代码,而不是这篇文章。
2.3.推荐阅读
3.Spock是什么
3.1.简介
这里引用官方的介绍:
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, jMock, RSpec, Groovy, Scala, Vulcans, and other fascinating life forms.
简单地说,spock是一个测试框架,它的核心特性有以下几个:
- 可以应用于java或groovy应用的单元测试框架。
- 测试代码使用基于groovy语言扩展而成的规范说明语言(specification language)。
- 通过junit runner调用测试,兼容绝大部分junit的运行场景(ide,构建工具,持续集成等)。
- 框架的设计思路参考了JUnit,jMock,RSpec,Groovy,Scala,Vulcans……
要理解spock的几个特性,还要理解几个关键名词:
3.1.1.groovy
引用维基百科上的介绍:
Groovy是Java平台上设计的面向对象编程语言。这门动态语言拥有类似Python、Ruby和Smalltalk中的一些特性,可以作为Java平台的脚本语言使用。 Groovy的语法与Java非常相似,以至于多数的Java代码也是正确的Groovy代码。Groovy代码动态的被编译器转换成Java字节码。由于其运行在JVM上的特性,Groovy可以使用其他Java语言编写的库。
groovy是一门比较轻量,学习门槛也比较低的语言。对于只用过java语言的程序员来说,groovy是一个很不错的开拓视野的机会。如果你没有接触过groovy,那么可以参考这两条:
- 可以用纯java的语法写groovy。
- 参考这篇快速入门。
我个人比较喜欢groovy语言,在一些小项目中经常使用它。引用一下R大在知乎的回复:
Groovy比较讨好来自Java的程序员的一点是:用它写代码可以渐进的从接近Java的风格进化为接近Ruby的风格。使用接近Java风格写Groovy时,代码几乎跟Java一样,容易上手;而学习过程中可以逐渐用上各种类似Ruby的方便功能。
3.1.2.specification language
如果接触过不同语言类型的开源项目的话,就会发现有些项目中找不到测试目录(test),取而代之的是一个叫“spec”的目录,比如用ruby写的项目gitlab。这里的spec实际是specification的缩写,它的背后是一种近些年来开始流行起来的编程思想:BDD(Behavior-driven development)。
关于BDD,同样是引用维基百科上的介绍:
BDD:行为驱动开发是一种敏捷软件开发的技术,它鼓励软件项目中的开发者、QA和非技术人员或商业参与者之间的协作。BDD最初是由Dan North在2003年命名,它包括验收测试和客户测试驱动等的极限编程的实践,作为对测试驱动开发的回应。 BDD的做法包括: 确立不同利益相关者要实现的远景目标 使用特性注入方法绘制出达到这些目标所需要的特性 通过由外及内的软件开发方法,把涉及到的利益相关者融入到实现的过程中 使用例子来描述应用程序的行为或代码的每个单元 通过自动运行这些例子,提供快速反馈,进行回归测试 使用“应当(should)”来描述软件的行为,以帮助阐明代码的职责,以及回答对该软件的功能性的质疑 使用“确保(ensure)”来描述软件的职责,以把代码本身的效用与其他单元(element)代码带来的边际效用中区分出来。 使用mock作为还未编写的相关代码模块的替身
BDD背后的编程思想超出了这篇文章的范围,这里就不再展开。上文说的specification language实际上是BDD其中一部分思想的实现手段:通过某种规范说明语言去描述程序“应该”做什么,再通过一个测试框架读取这些描述、并验证应用程序是否符合预期。
3.1.3.单元测试的运行场景
测试只有被执行之后才会有价值,这里就涉及一个“什么时候执行单元测试”的问题。
- 被接触最多的就是在IDE中执行单元测试,java程序员比较幸运,主流的java IDE都可以很好的集成了单元测试功能,单元测试代码自动生成、测试覆盖率检查等功能也都成了IDE的标配。这些功能都能让程序员在编写代码的时候直接可以运行单元测试得到反馈。
- 其次,主流的构建工具(如maven、gradle)中也都实现了运行单元测试的功能,在生成二进制包之前可以对代码进行回归测试,这些构建工具都可以通过命令行调用,这是自动化构建的前提。
- 在此之上,依托于构建工具提供的自动化特性,在持续集成、持续部署的过程中可以执行自动化构建,在自动化构建的过程中通过构建工具执行单元测试,这是持续集成的流程中的重要步骤。
3.2.Spock与现有框架的对比
3.2.1.已有的java单元测试框架
就像刚才说的,有很多已有的单元测试框架,稍微老一点的如JMockit、EasyMock,新一点的类似Mockito和PowerMock。我之前一直在用testng+Mockito作为主要的单元测试框架,用它写过大概上万行单元测试,它的写法相对来说比较易读,功能也能满足大多数场景。
但在使用mockito的过程中也总是有一些不是很方便的地方,比如代码的可读性总还是差那么一点,比如像这样:
1 2 3 4 5 6 7 8 9 10 | @Test public void testIsUserEnabled_userStatusIsClosed_returnFalse() throws Exception { UserInfo userInfo = new UserInfo(); userInfo.status = UserInfo.CLOSED; doReturn(userInfo).when(userDao).getUserInfo(anyLong()); boolean isUserEnabled = userService.isUserEnabled(1l); Assert.assertFalse(isUserEnabled); } |
虽然能读懂,但是对于它所做的事情全来说感觉说了很多废话,单元测试代码总是里充斥着各种when(),anyXXX(),return()之类啰嗦的关键词,加上java本身就是一个啰嗦的强类型的语言,这让写单测和读单测成为了一种体力活。
其次是单测数据,大部分测试都要提供数据,比如“当输入a的时候应该返回b”,如果只有一组数据那么没什么问题,但是当需要测试很多边界条件,需要多组数据的时候就会比较纠结。
用jUnit或者testng的dataprovider可以实现这个需求,但是无论是通过xml定义还是通过函数返回数据,都非常不方便。
最后,因为这些框架都只是一些独立的函数,没有告诉你“应该怎么写单测”,所以不同的人最终写出来的单测也是五花八门:
- 有不用assert而是用system.out.println的
- 有单测一个函数写了好几百行的
- 有直接把单测当成main函数写的
最终,团队要接受“虽然确实写了单测,然而这并没有什么卵用”的结果。
3.2.2.为什么使用spock
还是刚才的例子,如果用spock写的话:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | def "isUserEnabled should return true only if user status is enabled" () { given: UserInfo userInfo = new UserInfo( status: actualUserStatus ); userDao.getUserInfo(_) >> userInfo; expect: userService.isUserEnabled(1l) == expectedEnabled; where: actualUserStatus | expectedEnabled UserInfo.ENABLED | true UserInfo.INIT | false UserInfo.CLOSED | false } |
这段代码实际是3个测试:当getUserInfo返回的用户状态分别为ENABLED、INIT和CLOSED时,验证各自isUserEnabled函数的返回是否符合期待。
我对于spock框架最直接的感受:
- spock框架使用标签分隔单元测试中不同的代码,更加规范,也符合实际写单元测试的思路
- 代码写起来更简洁、优雅、易于理解
- 由于使用groovy语言,所以也可以享受到脚本语言带来的便利
- 底层基于jUnit,不需要额外的运行框架
- 已经发布了1.0版本,基本没有比较严重的bug
3.2.3.为什么不用spock
用了一段时间的spock后,我也总结了几个不用spock的理由:
- 框架相对比较新,IDE的支持(尤其是eclipse)不如其它成熟的框架
- groovy语言本身的compiler更新比较快,偶尔有坑(版本不兼容等)
- 需要了解groovy语言
- 与其它java的测试框架风格相差比较大,需要适应
当然,这些理由比起spock提供的易于开发和维护的单元测试代码来说,都是可以忽略的。
4.使用Spock
写到这里,还是要聚焦一下这篇文章要讨论的问题:如何用spock框架编写单元测试,在此之前再强调一下:
- 单元测试不一定非要使用spock,但是其它框架写出的单元测试代码远没有用spock框架优雅。
- spock框架并不只能写单元测试,它也可以写集成测试,甚至性能测试,但是后两者spock相对于其它框架来说没有什么优势。
4.1.关于开发环境
在使用spock框架时,我比较推荐的ide是IDEA,推荐的构建工具是gradle。
就算不使用spock框架,IDEA的顺手程度也比eclipse好太多,对新技术的响应速度快,也没有那么多莫名其妙的严重bug,社区版免费但主要功能都有,没有什么理由不试用一下。
而gradle相对于maven来说配置简化了很多,可定制的功能也更强,与其迷失在maven复杂的xml和一层套一层的依赖关系中,我宁愿把时间做一些更有意思的事情。
由于IDE基本可以自由选择,但构建工具大部分是由团队决定的,而maven现在还是处于构建工具的领导地位,所以这篇文章里的步骤都是基于IDEA+maven,当前的IDEA已经支持spock,不需要做什么特殊配置。
- 如果你的团队应用了gradle,spock官网中对于gradle如何配置说的比较完整,可以直接参考官网。
- 如果你执迷不悟非要使用eclipse,我在eclipse下也跑通了整个流程。需要安装最新的groovy-eclipse插件和附加包(安装时选择groovy2.4版以上的compiler),地址:https://github.com/groovy/groovy-eclipse/wiki
4.2.hello spock
前面做了那么多铺垫,终于到了真正编写一个hello world的时候。
到这里,我假设你是一位java开发者,并且已经了解基本的IDE及构建工具的使用。
- 创建一个空白项目:hello_spock,选择maven工程。
- 在pom.xml中增加依赖: 123456789101112131415161718192021222324252627282930313233343536<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>hello</groupId> <artifactId>hello_spock</artifactId> <version>1.0-SNAPSHOT</version> <dependencies> <!-- Mandatory dependencies for using Spock --> <dependency> <groupId>org.spockframework</groupId> <artifactId>spock-core</artifactId> <version>1.0-groovy-2.4</version> <scope>test</scope> </dependency> <!-- Optional dependencies for using Spock --> <dependency> <!-- use a specific Groovy version rather than the one specified by spock-core --> <groupId>org.codehaus.groovy</groupId> <artifactId>groovy-all</artifactId> <version>2.4.3</version> </dependency> <dependency> <!-- enables mocking of classes (in addition to interfaces) --> <groupId>cglib</groupId> <artifactId>cglib-nodep</artifactId> <version>3.1</version> <scope>test</scope> </dependency> <dependency><!-- enables mocking of classes without default constructor (together with CGLIB) --> <groupId>org.objenesis</groupId> <artifactId>objenesis</artifactId> <version>2.1</version> <scope>test</scope> </dependency> </dependencies></project>
- 由于spock是基于groovy语言的,所以需要创建groovy的测试源码目录:首先在test目录下创建名为groovy的目录,之后将它设为测试源码目录。
- 创建一个简单的类: 12345public class Sum { public int sum(int first, int second) { return first + second; }}
- 创建测试类,可以手工创建,也可以使用IDEA的辅助创建:
- 编写测试代码,这里我们验证一下sum返回的结果是否正确: 12345678import spock.lang.Specificationclass SumTest extends Specification { def sum = new Sum(); def "sum should return param1+param2"() { expect: sum.sum(1,1) == 2 } }
- 运行一下测试:
至此,一个最简单的spock测试就写完了。
4.3.Spock中的概念
4.3.1.Specification
在Spock中,待测系统(system under test; SUT) 的行为是由规格(specification) 所定义的。在使用Spock框架编写测试时,测试类需要继承自Specification类。
4.3.2.Fields
Specification类中可以定义字段,这些字段在运行每个测试方法前会被重新初始化,跟放在setup()里是一个效果。
1 2 | def obj = new ClassUnderSpecification() def coll = new Collaborator() |
4.3.3.Fixture Methods
预先定义的几个固定的函数,与junit或testng中类似,不多解释了
1 2 3 4 | def setup() {} // run before every feature method def cleanup() {} // run after every feature method def setupSpec() {} // run before the first feature method def cleanupSpec() {} // run after the last feature method |
4.3.4.Feature methods
这是Spock规格(Specification)的核心,其描述了SUT应具备的各项行为。每个Specification都会包含一组相关的Feature methods,如要测试1+1是否等于2,可以编写一个函数:
1 2 3 4 | def "sum should return param1+param2" () { expect: sum.sum( 1 , 1 ) == 2 } |
4.3.5.blocks
每个feature method又被划分为不同的block,不同的block处于测试执行的不同阶段,在测试运行时,各个block按照不同的顺序和规则被执行,如下图:
下面分别解释一下各个block的用途。
4.3.6.Setup Blocks
setup也可以写成given,在这个block中会放置与这个测试函数相关的初始化程序,如:
1 2 3 | setup: def stack = new Stack() def elem = "push me" |
一般会在这个block中定义局部变量,定义mock函数等。
4.3.7.When and Then Blocks
when与then需要搭配使用,在when中执行待测试的函数,在then中判断是否符合预期,如:
1 2 3 4 5 6 7 | when: stack.push(elem) then: !stack.empty stack.size() == 1 stack.peek() == elem |
4.3.7.1.断言
条件类似junit中的assert,就像上面的例子,在then或expect中会默认assert所有返回值是boolean型的顶级语句。如果要在其它地方增加断言,需要显式增加assert关键字,如:
1 2 3 4 | def setup() { stack = new Stack() assert stack.empty } |
4.3.7.2.异常断言
如果要验证有没有抛出异常,可以用thrown(),如下:
1 2 3 4 5 6 | when: stack.pop() then: thrown(EmptyStackException) stack.empty |
要获取抛出的异常对象,可以用以下语法:
1 2 3 4 5 6 | when: stack.pop() then: def e = thrown(EmptyStackException) e.cause == null |
如果要验证没有抛出某种异常,可以用notThrown():
1 2 3 4 null |