• 有效的单元测试


    一、 说明

    本文来自一本书《有效的单元测试》。这本书很薄,基于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
        }
    } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    上面例子中,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 //坏味道:只检查了验证码不为空,没有校验具体的规则
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    上面例子中,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 //坏味道:无效的断言,只检查了验证码不为空,没有校验具体的规则
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    上面例子中,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
        }
    } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    上面的例子中存在很多影响阅读的问题,变量命名比较随意、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  //校验用户名
        }
        
    } 
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77

    上面的例子,通过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》

  • 相关阅读:
    uniapp轮播图闪烁卡屏解决办法很简单
    Unixbench——2D图形性能测试方法及工具下载
    Python中判断两个集合是否相交的方法 - isdisjoint()
    pytorch中常用损失函数总结
    【(数据结构)— 单链表的实现】
    STM32F1 RTC 当成 ms 等级计时/延迟使用
    深入理解RocketMQ 广播消费
    ISO 8601持续时间格式
    2022.06青少年软件编程(Python)等级考试试卷(五级)
    i.mx6ull搭建boa服务器详解及其中遇到的一些问题
  • 原文地址:https://blog.csdn.net/kid_2412/article/details/126808494