本文来自一本书《有效的单元测试》。这本书很薄,基于java语言阐述了如何进行高效的单元测试(单测)。书中阐述了:
作者认为,单测代码应与业务逻辑代码具有相同的待遇(简洁,稳定,可靠)。
本文会围绕着书中所阐述的基本原则,说说自己日常开发单测的实践经验。这些个人经验不拘泥于语言(这里采用java),可适用于各种语言和系统工程。
有的人会问,为什么要做单测?不单测直接部署测试服测不也可以吗?
这是个好问题。回答这个问题,我们先来看看一个软件是怎么创造出来的。
举个例子,有一个叫大冤种的人买彩票中了100万,他看美团做外卖很赚钱嘛,于是就要花1万块钱找人做个跟美团外卖相同的系统干掉美团(当然我们知道,这是不可能的)。
于是,大冤种找到了程序员小博,小博很开心的接下来这个外卖系统的开发任务。小博作为牵头人,拉起了老易、小迪俩位好朋友一起开发这个项目。人员整体分工如下:
- 小博负责需求跟踪、分析设计(当然由于功能较多,人力紧张,也是要做实际开发任务的)
小博将这个文档分发给老易和小迪,并开会宣讲,分析每个功能模块具有哪些细节,用户怎么操作等等。需求宣讲完,老易开始根据所要实现的目标进行系统架构设计(方案设计)。老易方案设计完成后,拉会与小博、小迪对齐系统架构设计,主要说明采用什么技术解决什么问题。系统架构设计宣讲完,小博和老易开始进行开发前的准备(技术难点调研、实验、工程脚手架搭建等),同时小迪根据需求文档设计测试用例。在前期准备就绪后,小迪的用例也设计完成,一样需要拉着小博、老易评审测试用例。
终于一切都准备妥当,步入正式开发。经过一年的没日没夜的奋斗,小迪和老易完成了所有模块的开发,并向小迪提了测。此时的小迪,已经有了孩子。小迪只好白天带娃,晚上掏出一年前的测试用例文档,一边回忆着当时会上讨论的需求和方案设计,一边进行测试。
经过了第一轮测试,小迪给老易提了2000多个bug,给小博提了2个bug。
小迪:老易,你给我说说,你到底有没有做自测?怎么这么多bug?
老易:废话,当然测了
小迪:那你看看人家小博,才2个bug,这才叫自测了
老易:我tm部署在测试服测的,咋不算自测?
小迪:那你怎么这么多bug?
老易:咱们的资源投入有限,就一台测试服,才512m内存,要跑数据库、缓存服务,还要跑我们的代码,卡死了。测试服部署一次要20分钟。可不就测的慢了,测的不准。
小博:你为啥不单测呢?单测在本地很快呀,启动一个单测才1秒钟。
老易:单测是啥?
小迪、小博:…
于是,老易步入了修改bug,提测,打回继续修改的死循环中。
时间飞逝过了5年,老易终于修改完了他所有的bug。这个外卖软件终于能上线了。
由于6年里,中国互联网发生了巨大的变化,美团外卖已经普及大众。大冤种的项目上线后,根本没人使用,他的100万也支撑不了多久全都花光了。不过老易、小博、小迪三个人6年间赚了1万块钱,人均3200元,还是可喜可贺的!
上面鬼扯了这么多,重点:
下图展示了在整个软件生命周期内,随着时间的增长(即流转入不同阶段),出现问题并解决问题,所带来的成本也是逐渐增加的。
以下是引自《有效的单元测试》一书中,美国某公司对问题发生后的成本分析:
一个优秀的程序员或架构师应该能够使用各种手段,降低成本、预测并解决风险、提升效率。
从开发的角度,我们可以通过单元测试,减少bug出现的频率,提升软件整体质量。降低后续集成测试的成本(包括自测阶段的集成测试,提测阶段的集成测试)。
你可能会问,自测阶段的集成测试有什么成本?
答:部署集成环境的成本,集成环境下,跑通一个功能的整个链路,可能会涉及到数据库、缓存、第三方接口等依赖。这些都会引入未知的问题。在单测环境下,可针对这些依赖进行Mock,隔离依赖。
上面说了一堆都是在佐证单测的重要性。虽然我们现在知道了单测是可以解决很多问题的,但是编写单测用例并进行长期维护是有很大成本的。成本在于:
这里第四点加上…缩略,是代表还有更多的成本可以被统计出来。列出所有成本对本文并没有什么意义,我们可以反过来列出有问题的单测,从反面印证,烘托一下写出有效的单测是多么重要。下面我们就嗅嗅单测的坏味道,是多么的臭(跟屎一样)!
对于坏味道的单测可分为以下几类:
这里,我对每个类别举出例子(以Spock为例),说明坏味道的单测会带来哪些危害:
a.职责混乱
Spock代码示例:
class SpockTest extends Specification {
UserService userService = new UserService()
VerificationCodeService codeService = new VerificationCodeService()
def "register user"() {
when:
def username = "bd7xzz"
def password = "123456"
def code = codeService.getCode(username) //坏味道:获取验证码,用户case强依赖了验证码服务
userService.register(username, password, code)
def user = userService.login(username,password) //坏味道:通过调用登录方法验证用户的有效性,case包含了登录的业务逻辑
def codeState = codeService.checkCode(code, username) //坏味道:校验验证码的有效性
then:
user.getUername() == username
codeState == true
}
}
上面例子中,UserService提供了用户服务,UserService.register()
提供了注册业务能力,UserService.login()
提供了登录业务能力;
VerificationCodeService提供了验证码服务,VerificationCodeService.getCode()
提供了获取验证码的能力,VerificationCodeService.checkCode()
提供了校验验证码有效性的能力。
本case被命名为register user,本意为验证注册用户UserService.login()
,却在第10、14行依赖并校验了验证码服务,在第12行基于用户登录验证注册的有效性。
整个case的职责比较混乱。倘若修改了登录或者验证码服务的接口参数,注册case也会运行失败。
b.外部依赖
Spock代码示例:
class SpockTest extends Specification {
VerificationCodeService codeService = new VerificationCodeService(
aliyunSDKService: new AliyunSDKService(), //坏味道:依赖阿里云短信服务
userDao: new UserDao() //坏味道:依赖用户数据Dao
)
def "get verification code"() {
when:
def username = "bd7xzz"
def code = codeService.getCode(username) //getCode方法调用了阿里云短信服务,并把验证码写入用户数据表
then:
"" != code //坏味道:只检查了验证码不为空,没有校验具体的规则
}
}
上面例子中,VerificationCodeService提供了验证码服务,VerificationCodeService.getCode()
提供了获取验证码的能力,该方法调用了阿里云短信服务,并把验证码写入用户数据表。
阿里云短信服务需要提交网络请求向阿里云服务器,若case跑在离线或者弱网环境下,调用短信服务失败,case运行失败。用户数据Dao依赖数据库,若数据库挂掉,写入数据库失败,case运行失败。
除了依赖数据库、第三方服务,对系统时间和随机数依赖也会导致case每次跑出的结果不同(出现失败的情况),如:
assert result > System.currentTimeMillis()
系统时间可能回拨,导致断言失败assert result == ThreadLocalRandom.current()
随机数本身不确定,导致断言失败(也是无意义的断言)Thread.sleep()
并不靠谱,若通过CountDownLatch可能会对业务代码有侵入,同时由于线程耗时不确定,case需要hang在那里等待所有线程都执行完(严重增加自动化回归的成本)。c.错误的断言
Spock代码示例:
class SpockTest extends Specification {
VerificationCodeService codeService = new VerificationCodeService()
def "get verification code"() {
when:
def username = "bd7xzz"
def code = codeService.getCode(username)
then:
"" != code //坏味道:无效的断言,只检查了验证码不为空,没有校验具体的规则
}
}
上面例子中,VerificationCodeService提供了验证码服务,VerificationCodeService.getCode()
提供了获取验证码的能力。本case的目标是校验获取验证码的有效性,应该包括:验证码位数、复杂度(字符范围)等。这里只是进行了判空断言,属于无效错误的断言。
错误的断言中还包括无断言,这是一个很危险的行为,因为无断言case可能会百分之百测试通过,因此会隐藏掉bug。单测中控制台输出或打印日志是无法校验代码有效性的。
d. 可阅读性差
Spock代码示例:
class SpockTest extends Specification {
UserService service1= new UserService() //坏味道:变量名区分度差
VerificationCodeService service2= new VerificationCodeService() //坏味道:变量名区分度差
def "test"() { //坏味道:case方法名无法表达出case的意义
when:
def a= "bd7xzz" //坏味道:变量名区分度差
def b= "123456" //坏味道:变量名区分度差
def c= service2.getCode(a) //坏味道:变量名区分度差
service1.register(b, c, a) //坏味道:因为变量名区分度差,参数传错了
def u= service1.login(a,b) //坏味道:变量名区分度差
def cs= service2.checkCode(c,b ) //坏味道:变量名区分度差
then:
u.getUername() == a
}
}
上面的例子中存在很多影响阅读的问题,变量命名比较随意、case方法名无法表达case的含义,甚至因为变量名比较随意导致第10行参数都传错了。这里只是一个简单的示例,如果一个功能比较复杂的项目,到处充斥着这样随意的命名方式,且没有合理的注释(或大量随意的注释淹没了编辑区)。那case可能无法根据功能变更而快速变更,需要大量时间梳理混乱的结构和引用。
所以,对于单测代码,也需要像正式的业务逻辑代码一样,具有良好的编码规范,提交可阅读性和可维护性。
上面给出了坏味道的单测例子,并说明了会带来哪些不好的影响。接下来,基于坏味道作为例子,反推一下有效的单元测试是如何编写的。
我们说,一个有效的单测要具有以下特性:
以下将坏味道中的case合并起来,修复其中的坏味道,让我们的例子变为有效的单元测试。
Spock示例:
class SpockTest extends Specification {
UserService userService = new UserService(
userDao: Mock(UserDao.class),
codeService: Mock(VerificationCodeService.class)
)
@Unroll
def "register user"() {
when:
userService.codeService.checkCode(*_) >> true //Mock验证码校验正确,屏蔽对验证码服务的依赖
userService.register(username, password, code)
then:
(expactFlag ? 1 : 0) * userService.userDao.insertUser({ //验证insertUser调用次数
def user = it as UserPO
assert user.username == username //验证用户名是否是预期输入的
assert user.password == Md5Utils.hexString(password) //验证密码是否是预期输入的(注意md5加密)
return user
})
where:
username | password | code | expectFlag
null | "123456" | "7xbd"| false //用户名为null的情况,insertUser不会被调用
"" | "123456" | "7xbd"| false //用户名为空字符串的情况,insertUser不会被调用
"bd7xzz" | null | "7xbd"| false //密码为null的情况,insertUser不会被调用
"bd7xzz" | "" | "7xbd"| false //密码为空字符串的情况,insertUser不会被调用
"bd7xzz" | "123456" | null | false //验证码为null的情况,insertUser不会被调用
"bd7xzz" | "123456" | "" | false //验证码为空字符串的情况,insertUser不会被调用
"bd7xzz" | "123456" | "7xbd"| true //输入了用户名、密码、验证码的情况下,insertUser不会被调用
}
@Unroll
def "login user when error"() {
when:
userService.userDao.getUser("bd7xzz","123456") >> null //Mock用户Dao,屏蔽数据库,档username为bdx7zz,password为123456给出输入用户名密码错误的结果
userService.login(username, password)
then:
thrown(InvalidUserException) //验证抛出无效用户异常
where:
//以下参数都会抛出异常InvalidUserException
username | password
null | "123456" //用户名为null的情况,不会调用getUser
"" | "123456" //用户名为空字符串的情况,不会调用getUser
"bd7xzz" | null //密码为null的情况,不会调用getUser
"bd7xzz" | "" //密码为空字符串的情况,不会调用getUser
"bd7xzz" | "123456" //输入用户名密码,会调用getUser
}
def "check login user paramter"() {
when:
def username = "bd7xzz"
def password = "123456"
userService.login(username, password)
then:
1 * userSerivce.userDao.getUser({ //验证getUser会被调用1次
def uname = it as String //验证用户名
assert uname == "bd7xzz"
return uname
},{
def pwd = it as String //验证用户密码,注意md5加密
assert pwd == MD5Utils.hexString("123456")
return pwd
})
}
def "check login user success"() {
when:
def username = "bd7xzz"
def password = "123456"
userService.userDao.getUser(*_) >> UserPO.builder().username(username).build() //Mock 用户Dao,屏蔽数据库,返回查询成功的用户对象
def result = userService.login(username, password)
then:
null != result //校验结果不为空
result.username == username //校验用户名
}
}
上面的例子,通过Spock 单测框架,编写测试用例。
覆盖了正常情况(注册成功、登录成功)和异常情况(登录密码错误)、边界(用户名或密码为空)。使用Spock的数据驱动(where)传递用户注册、登录的输入参数,同时通过Spock的Mock屏蔽了数据库、短信验证码服务,防止因为外部依赖抖动、异常导致的case不稳定。断言校验参数字段、返回结果、抛出的异常。对不同的测试参数给出适当的注释。
Spock是基于Java/Groovy工程的单元测试,框架比较灵活强大,只要mavn pom中引入坐标即可使用。采用Goovy语言编写用例,开发和运行速度快。使用Spock单测的大型项目不需要加载Spring庞大的配置和诸多对象,跑单测效率高于Junit(当然Spock也可快速集成Spring)。
Spock提倡采用TDD(测试驱动开发)准则推进开发,而对于有敏捷经验的团队,可采用BDD(基于行为的驱动开发)+ Spock推进开发。目前国内很多互联网一线大厂,已经逐渐从Junit迁移到Spock。
这里只给出了对坏味道的初步解决方案,《有效的单元测试》一书中,详细列举了所有bad case情况,并逐一给出了解决方案。同时给出了重构单测代码的指导意见,如果你读过《重构》和《代码整洁之道》的话,会对这些指导感觉很熟悉。
最后,说一下我对测试这个事情的看法:
本文阐述了单测的重要性,给出了错误的单测示范,针对错误反推出正确的姿势。
有效的单测要把握3个特性:简洁、稳定、可靠。使用合适的单测工具库有助于写出好的单测代码(如:使用Spock基于TDD准则进行测试)。
《代码整洁之道》和《重构》两本书中编写和重构代码的指导方法也同样适用于单测代码。《有效的单元测试》这本书非常推荐阅读。
[1]《有效的单元测试》
[2] 《代码整洁之道》
[3] 《重构:改善既有代码的设计》
[4] 《Spock Framework Reference Documentation》