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年的高级码农也是一个挑战,对新