有效的单元测试

一、 说明

本文来自一本书《有效的单元测试》。这本书很薄,基于java语言阐述了如何进行高效的单元测试(单测)。书中阐述了:

  1. 单测的重要性
  2. 软件整个生命周期内,每个阶段出现bug后修复带来的成本
  3. 什么是坏的单测?
  4. 如何重构这些bad case?

作者认为,单测代码应与业务逻辑代码具有相同的待遇(简洁,稳定,可靠)。

  • 简洁:可读性强,有良好的命名方式(变量、方法名等)、有适当的注释。便于维护。
  • 稳定:不依赖变化(随机、外部数据),每个单测可多次运行,且多次运行具有相同的结果。
  • 可靠:单测可代表用户用例,可验证用户输入的正常和异常情况。

本文会围绕着书中所阐述的基本原则,说说自己日常开发单测的实践经验。这些个人经验不拘泥于语言(这里采用java),可适用于各种语言和系统工程。

二、为什么要有单测?

有的人会问,为什么要做单测?不单测直接部署测试服测不也可以吗?
这是个好问题。回答这个问题,我们先来看看一个软件是怎么创造出来的。

举个例子,有一个叫大冤种的人买彩票中了100万,他看美团做外卖很赚钱嘛,于是就要花1万块钱找人做个跟美团外卖相同的系统干掉美团(当然我们知道,这是不可能的)。
于是,大冤种找到了程序员小博,小博很开心的接下来这个外卖系统的开发任务。小博作为牵头人,拉起了老易、小迪俩位好朋友一起开发这个项目。人员整体分工如下:

  1. 小博负责需求跟踪、分析设计(当然由于功能较多,人力紧张,也是要做实际开发任务的)
  1. 老易负责开发、架构设计(人称全栈易,前后端到运维都能干)
  2. 小迪负责测试
    小博经过大冤种多次交流沟通,将大冤种的一句话:我要做个外卖软件,干倒美团!逐渐细化成了一份需求文档。里面包含:
    a. 需求背景
    b. 预期达成的目标和效果
    c. 功能模块(包括原型图)

小博将这个文档分发给老易和小迪,并开会宣讲,分析每个功能模块具有哪些细节,用户怎么操作等等。需求宣讲完,老易开始根据所要实现的目标进行系统架构设计(方案设计)。老易方案设计完成后,拉会与小博、小迪对齐系统架构设计,主要说明采用什么技术解决什么问题。系统架构设计宣讲完,小博和老易开始进行开发前的准备(技术难点调研、实验、工程脚手架搭建等),同时小迪根据需求文档设计测试用例。在前期准备就绪后,小迪的用例也设计完成,一样需要拉着小博、老易评审测试用例。
终于一切都准备妥当,步入正式开发。经过一年的没日没夜的奋斗,小迪和老易完成了所有模块的开发,并向小迪提了测。此时的小迪,已经有了孩子。小迪只好白天带娃,晚上掏出一年前的测试用例文档,一边回忆着当时会上讨论的需求和方案设计,一边进行测试。
经过了第一轮测试,小迪给老易提了2000多个bug,给小博提了2个bug。

小迪:老易,你给我说说,你到底有没有做自测?怎么这么多bug?
老易:废话,当然测了
小迪:那你看看人家小博,才2个bug,这才叫自测了
老易:我tm部署在测试服测的,咋不算自测?
小迪:那你怎么这么多bug?
老易:咱们的资源投入有限,就一台测试服,才512m内存,要跑数据库、缓存服务,还要跑我们的代码,卡死了。测试服部署一次要20分钟。可不就测的慢了,测的不准。
小博:你为啥不单测呢?单测在本地很快呀,启动一个单测才1秒钟。
老易:单测是啥?
小迪、小博:…

于是,老易步入了修改bug,提测,打回继续修改的死循环中。
时间飞逝过了5年,老易终于修改完了他所有的bug。这个外卖软件终于能上线了。
由于6年里,中国互联网发生了巨大的变化,美团外卖已经普及大众。大冤种的项目上线后,根本没人使用,他的100万也支撑不了多久全都花光了。不过老易、小博、小迪三个人6年间赚了1万块钱,人均3200元,还是可喜可贺的!

上面鬼扯了这么多,重点:

  1. 软件的生命周期
  2. 一个软件系统中有哪些角色
  3. 不采用单测,带来哪些问题、增加了很大的成本

下图展示了在整个软件生命周期内,随着时间的增长(即流转入不同阶段),出现问题并解决问题,所带来的成本也是逐渐增加的。

  • 需求分析阶段发现遗漏的需求或不合理,可以通过沟通讨论纠正,成本较低。
  • 方案设计阶段发现的设计问题,可以通过沟通讨论纠正(多人评审),成本略有增加。增加的点在于可能有的需求以现有的技术是无法解决的。
  • 编码开发阶段,若发现方案设计阶段没有发现的设计问题,此时开发排期受阻。在开发阶段通过单元测试可消灭大量因为疏忽导致的bug。集成自测,可解决系统上下文产生的问题。
  • 开发提测后,步入集成测试阶段。若此时发现较多的bug,通常需要沟通并解决重新提测。此时带来的成本较大。尤其是因为修复bug产生新的bug,可能严重降低集成测试的效率。并占用开发人员、QA的多份人力。
  • 上线后产生的bug,小bug对用户体验产生影响,降低了用户粘性。而对于可引起系统故障的bug,可能彻底损失用户,甚至产生法律风险。

以下是引自《有效的单元测试》一书中,美国某公司对问题发生后的成本分析:

  • 不同阶段发现问题到解决的时间成本(小时):
  • 不同阶段发现问题到解决的人力成本(美元):

一个优秀的程序员或架构师应该能够使用各种手段,降低成本、预测并解决风险、提升效率。
从开发的角度,我们可以通过单元测试,减少bug出现的频率,提升软件整体质量。降低后续集成测试的成本(包括自测阶段的集成测试,提测阶段的集成测试)。

你可能会问,自测阶段的集成测试有什么成本?
答:部署集成环境的成本,集成环境下,跑通一个功能的整个链路,可能会涉及到数据库、缓存、第三方接口等依赖。这些都会引入未知的问题。在单测环境下,可针对这些依赖进行Mock,隔离依赖。

三、单测的坏味道

上面说了一堆都是在佐证单测的重要性。虽然我们现在知道了单测是可以解决很多问题的,但是编写单测用例并进行长期维护是有很大成本的。成本在于:

  1. 如果你的代码层次足够清晰,方法(函数)和类都保证了单一职责,低耦合高内聚。那么你抽象出来的东西必定会更多,产生的具体实现也会增长。因此你需要为每个具体实现方法都配备一个单测用例。在大型系统工程中,单测用例的数量可能远超于业务代码本身的量。
  2. 对于每次需求变更,你的代码都会有变动,这时你依然要维护好旧的单测用例,甚至新增单测用例。
  3. 你要保证你的单测用例别人可以看懂,可以维护。

这里第四点加上…缩略,是代表还有更多的成本可以被统计出来。列出所有成本对本文并没有什么意义,我们可以反过来列出有问题的单测,从反面印证,烘托一下写出有效的单测是多么重要。下面我们就嗅嗅单测的坏味道,是多么的臭(跟屎一样)!

对于坏味道的单测可分为以下几类:

  1. 职责混乱:即一个case中包含多个不同职责的验证点,如:验证注册功能,却包含了短信验证码相关case
  2. 外部依赖:依赖其他case的结果,或依赖外部接口、数据库等
  3. 不确定性:依赖系统时间、随机数、等待多线程的计算结果
  4. 可阅读性差:case验证点和注释不符、case的变量名方法名混乱、case分支太多、case行数过多
  5. 错误的断言:断言恒定为true或false、无断言(只是打印结果)、断言结果与业务逻辑不符、过度断言

这里,我对每个类别举出例子(以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() 随机数本身不确定,导致断言失败(也是无意义的断言)
  • 在多线程情况下,线程执行耗时不确定,显然在case中使用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可能无法根据功能变更而快速变更,需要大量时间梳理混乱的结构和引用。
所以,对于单测代码,也需要像正式的业务逻辑代码一样,具有良好的编码规范,提交可阅读性和可维护性。

四、有效的单测

上面给出了坏味道的单测例子,并说明了会带来哪些不好的影响。接下来,基于坏味道作为例子,反推一下有效的单元测试是如何编写的。
我们说,一个有效的单测要具有以下特性:

  1. 简洁:可阅读性强,职责单一,内聚性高,有相关的注释
  2. 稳定:可反复运行,每次结果具有确定性,屏蔽依赖、随机等不确定性
  3. 可靠:具有有效的断言,验证边界、异常情况,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情况,并逐一给出了解决方案。同时给出了重构单测代码的指导意见,如果你读过《重构》和《代码整洁之道》的话,会对这些指导感觉很熟悉。

最后,说一下我对测试这个事情的看法:

  1. 软件的生命周期里,方案设计和测试耗时必须要高于编码耗时。一个好的方案可避免产生大的问题,而良好的测试可避免细节上的问题。
  2. 优秀的程序员和架构师是最注重单测、自测的,没有测试的代码是不可靠的,不应出现在线上。
  3. 即使单测很枯燥,但请尊重你的测试用例,就跟敬畏线上环境一样。

五、总结

本文阐述了单测的重要性,给出了错误的单测示范,针对错误反推出正确的姿势。
有效的单测要把握3个特性:简洁、稳定、可靠。使用合适的单测工具库有助于写出好的单测代码(如:使用Spock基于TDD准则进行测试)。
《代码整洁之道》和《重构》两本书中编写和重构代码的指导方法也同样适用于单测代码。《有效的单元测试》这本书非常推荐阅读。


[1]《有效的单元测试》
[2] 《代码整洁之道》
[3] 《重构:改善既有代码的设计》
[4] 《Spock Framework Reference Documentation》

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

bd7xzz

大爷,来玩啊

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值