天天看点

写有价值的单元测试

这是写给开发同学系列文档中的一篇,主要讲单元测试。

写这个系列的原因是发现开发同学,尤其是偏业务的开发同学对于软件开发中的很多实践和理论理解的不够清楚。比如设计文档,代码评审,单元测试,集成测试和自动化测试,持续集成和持续发布这样一些耳熟能详的概念,说起来每个开发同学都听过,但很多人并没有深入考虑过为什么要引入这些实践,实践需要哪些手段,要达到什么目的,要坚持什么原则?所以这些实践落地的过程也是千差万别,效果往往也不甚理想。

通过这一系列文档,我会把我所了解的每个实践的来源、适用范围和价值用最简明的方式写出来,并结合具体的开发环境提供一些具体的操作手段,帮助同学们按照正确的路径快速了解和上手。

大数据就像青少年谈性,每个人都在说,但不知道谁做了。每个人认为另外人在做,所以每个人都声称自己在做。

这是大数据火的时候调侃大数据的段子,套在单元测试上也一样适用。说起来单元测试是开发应该了解的基础概念,然而实际并非如此。

我在若干团队(不限于淘宝)作过以下的调查,结果基本类似:

有多少人写过单元测试?

每个单元测试里面都有至少一个assert语句,而非使用system.out.println?

单元测试的行覆盖率超过50%?

每个单元测试平均每天会被执行10次以上?

所有单元测试一定不会因为断网失败?

大多数开发都认为自己写了单元测试,但大家写的"单元测试"都不相同。实际上,能达到最后一点要求的单元测试才是 有价值的单元测试 ,其它所谓的"单元测试"最多只能算是"测试代码"甚至"代码片段"。

"单元测试"和"测试代码"的区别就像钻石和煤炭,虽然都由碳元素(程序代码)构成,但前者价值远远大于后者。随着时间的流逝,"钻石恒久远,一颗永流传",而煤炭只会被烧掉,变成二氧化碳;同样,随着项目的不断演进,"单元测试"能一直存在并发挥价值,而"测试代码"只会被一行行注释掉,直到某个接手的同学把一个全被注释掉的测试文件从项目中删除。

要理解这一切发生的原因,首先我们需要了解软件开发中的几个定律:

程序员的错误产出比是个常数

对某一个程序员来说,实现相同功能会犯的错误(bug)是固定的,不受程序员自身意愿影响,不受绩效影响,也不受项目紧急程度影响。不考虑程序员水平的成长,错误产出比在很长一段时间(每个项目的间隔)内可以认为是个常数。

这个定律告诉我们,如果一个程序员感觉今天状态很好,比昨天多写了一倍代码并向qa声称bug数肯定比昨天还少,而qa测试的结果居然真的如此,那很大可能是qa测的不够仔细,而不是程序员的代码水平一夜之间突飞猛进。

这个定律还告诉我们,tl开会时要求程序员写代码尽量不要犯错没有任何意义,这种要求就像要求程序员明天长高5公分一样,不具有可操作性。

错误率恒定定律决定了错误数是一定的,但并不意味着这些错误产生的影响是一定的。恰恰相反,不同开发方式中,这些错误带来的影响差别非常巨大。菜鸟和老手完成相同功能发生的错误可能只差5倍,在具体项目实践中带来的影响却可能差了20倍、50倍甚至更多。原因就是以下的规模代价平方律

定位并修复一个bug所需的代价正比于目标代码规模的平方

如果一个20行的函数刚写完时作者就能发现有bug,找到并修复这个bug可能只需要1分钟,并且不担心影响其它使用者。如果是在200行的一个类中,别人调用时发现有bug,阅读代码并定位问题可能就需要一个小时,对这个问题的修复重新代码评审又要花一个小时。如果在系统和系统联调的时候才发现这个问题,前面扯皮就要半天,改完了重新回归又是半天。如果改这个bug的人已经不是原作者的话,往往担心改了这个bug又引入其它问题,于是修改方案就要拖一群人讨论半天,最终改完了要求qa作大范围的回归,结果还是还不放心。

规模代价平方律是很多软件工程实践的核心思想。根据平方律,为了减少错误修复的成本,要尽可能早的发现错误,在尽量小的范围内定位并修复错误。由于这是一个平方律而非线性率,所有这方面的努力都是非常划算的。比如以下实践很大程度上就是为了尽早发现错误,以后有机会我再逐一介绍这些实践。

设计评审

代码评审

单元测试

自动化测试

结对编程

(scrum)小迭代,迭代后期的成果演示

规模代价平方律对程序员的重要性可以和牛顿三定律在初等物理中的地位媲美 。遗憾的是很多程序员写了很多年都不知道这个定律律,往往低估了错误修复的时间。所以业界对程序员的自我评估有如下经典的吐槽

当一个程序员宣称他已经完成了90%的工作时,他的意思是还需要相同的时间来完成剩下10%的工作

错误率恒定律告诉我们错误是不可避免的,而规模代价平方律告诉我们要尽早发现错误。单元测试作为一个行之有效的工程实践,目的只有一个

单元测试的目的是尽早在尽量小的范围内暴露错误

不同项目的单元测试方案各有不同,各种方案的选型往往也会有争议。这时候一定要记住单元测试的目的。凡是利于此目的,即使复杂一些的方案或有一定学习成本也可以采用,凡是不利于此目的的方案,即使看起来很美也没有采用的必要(本文最后有几个单测的误区具体说明这点)

为了达到 尽早 和 尽量小的范围 以及 暴露错误,对单测有以下要求。 实践证明,只有满足这些要求的单测才能实现单测的目的。

有些同学不喜欢用assert,而喜欢在test case中写个system.out.println,人肉观察一下结果,确定结果是否正确。这种写法根本不是单测,原因是即使当时被测试代码是正确的,后续这些代码还有可能被修改,而一旦这些代码被改错了。println根本不会报错,测试正常通过只会带来虚假的自信心,这种所谓的"单测"连暴露错误的作用都起不到,根本就不应该存在。

有些同学写的测试里面会有assert,但用的很少,往往只是在最后用一个assertnotnull(result),这样的测试强度是不够的。举个例子,假设有以下的待测方法

以下的测试用例强度就太差了,这个用例虽然也用了assert,但对测试的结果校验很弱,即没有校验结果中有多少user,也没有校验双向模糊逻辑是否正确实现了。实际上即使查询结果是空,返回的也是个empty list,测试用例还是不会报错。

单测要能反应函数的明确需求才算有强度。这样以后 函数的实现一旦被改错了单测才能尽快报错, 针对以上这个例子,单测至少要达到以下强度

强度是指单元测试中对结果的验证要全面,覆盖度则是指测试用例本身的设计要覆盖被测试程序(sut, sysem under test)尽可能多的逻辑。只有覆盖度和强度都比较高才能较好的实现单测的目的。

按照测试理论,sut的覆盖度分为方法覆盖度,行覆盖度,分支覆盖度和组合分支覆盖度几类。不同的系统对单测覆盖度的要求不尽相同,但这是有底线的。一般来说,程序配套的 单测至少要达到>80%的方法覆盖以及>60%的行覆盖 ,才能起到"看门狗"的作用,也才是有维护价值的单测。

和集成测试不同,单元测试的粒度一定要小,只有粒度小才能在出错时尽快定位到出错的地点。单测的粒度最大是类,一般是方法。单测不负责检查跨类或者跨系统的交互逻辑 , 那都是集成测试的范围。

通俗的说,程序员写单测的目的是"擦好自己的屁股",把自己的代码从实现中隔离出来,在集成测试前先保证自己的代码没有逻辑问题。至于集成测试乃至其它测试中暴露出来的接口理解不一致或者性能问题,那都不在单元测试的范围内。

单元测试通常会被放到持续集成(ci)中,每次有代码check in时单元测试都会被执行。如果单测依赖有对外部环境(网络、服务、中间件)的依赖,任何一次网络抖动或者返回的变化都会造成单测失败进而造成持续集成的失败。这会造成整个持续集成有大量误报,进而导致持续集成机制的不可用。所以 单测不能受到外界环境的影响。

为了不受外界环境影响,要求设计代码时就把sut的依赖改成注入,在测试时用spring或者guice这样的di框架注入一个本地(内存)实现或者mock实现。用这种方法保证在sut出错时单测才会报错,持续集成才能更稳定,单测的失败也才更重要。

作为"看门狗",最好是在每次代码有修改时都运行单元测试,这样才能尽快的发现问题。这就要求单元测试的运行一定要快。一般要求 单个测试的运行时间不超过3秒 , 而整个项目的单测时间控制在3分钟之内,这样才能在持续集成中尽快暴露问题。

单测不仅仅是给持续集成跑的,跑测试更多的是程序员本身, 单测速度和程序员跑单测的意愿成反比 ,如果单测只要5秒,程序员会经常跑单测,去享受一下全绿灯的满足感,可如果单测要跑5分钟,能在提交前跑一下单测就不错了。

实际上,上一条要求将单测的外部依赖全部改成本地实现或者mock,除了系统稳定性外,执行速度也是考量之一。改成本地实现或者mock后,绝大多数单测运行的时间都非常快,基本上可以说是瞬间就能跑完。

明确了单测的目标之后,单测方案的选型也比较明确了。原则就是 本地化 和 快,选型上也尽量以内存方案为主。

下面我们以spring boot开发为例,给出一套解决方案,以下代码都是以spring annotation configuration给出的,如果有必要也可以换成xml

数据库测试多用在dao中,dao对数据库的操作依赖于mybatis的sql mapper 文件,这些sql mapper多是手工写的,在单测中验证所有sql mapper的正确性非常重要,在dao层有足够的覆盖度和强度后,service层的单测才能仅仅关注自身的业务逻辑。

数据库单测方案需要解决3个问题:

schema的初始化和同步

每个测试完成后的数据清除

调试过程查看数据库内容

下面的方案中对这3个问题都给出了方法

在pom中引入h2的依赖

在测试资源目录 src/test/resource 下新建 your_module.sql,其中的内容是需要初始化的建表语句,这些建表语句可以从idb中导出。如果表结构发生了更改,需要人工重新导出。

写有价值的单元测试

创建dao接口,注意这里不用写实现。只要按照一定的规范,可以动态生成所有dao的实现,自动映射到相应的sql mapper中

在程序的资源目录src/main/resources/sqlmapper中放置对应的sqlmapper文件,文件名最好和dao对应,以便人工查找,比如这里对应的sqlmapper就叫hsfdatasourcemetadao.xml。注意!为了实现自动映射, mapper的namespace一定要和dao的类名相同

在测试目录 src/test/java/com/taobao/.../your_module/config 下新建测试配置

写一个数据库单测的基类,后续的数据库单测继承这个基类即可

然后就可以写数据库单测了

到此为止搭建了一整套基于h2的数据库单测方案。这套方案基于内存,没有对外部的依赖,可以在单机上飞快的跑完 mvn clean test。

对于前文提到的三个问题,这个方案是这么解决的

写有价值的单元测试
写有价值的单元测试
写有价值的单元测试

是否使用mock的争议一直很大,反对者认为mock要额外写很多代码,同时mock通过并不能保证线上工作正常,而支持者认为mock速度快并且稳定,这就是最大的作用。而我们觉得在单元测试中mock外部依赖还是合理的。一方面单元测试的目的是"擦好自己的屁股",对接口的理解错误应该在(自动化)集成测试而不是单元测试中去检测,另一方面mock的使用范围仅仅是边界上的外部依赖,其使用还是可控的。

下面以一个例子说明如何用easymock写单元测试

这是需要测试的代码

其中uicserive定义如下,这是一个hsf的远程服务

黑名单提供者有一个本地实现

白名单则需要通过hsf远程获取

由于代码中有两个对外部服务的依赖,想在实际环境中去验证canaccess代价很大。一方面跨系统准备数据很麻烦;另一方面即使当时数据准备好了,过两天可能又没了。这个验证没法自动化就没法到持续集成,也就称不上单元测试。

通过使用mock,可以让这两个外部依赖按照我们的想法返回数据,准备数据变得容易,单测的强度也就容易提升。

首先在pom中引入easymock

然后开始写单测

这里不展开将easymock的语法。一般来说一个测试用例需要三个步骤,如上例中的step1~step3

准备数据: 构造mock对象或本地对象,组装对象

测试和断言: 这步主要考虑测试覆盖率

验证mock对象的行为:这是使用mock特有的行为,可选。

这个例子比较简单,但仍然有几点值得注意的地方

为了可测试性,被测试的对象(accesscontrolservice)所用到的依赖一定要作成可注入的(依赖翻转,提供setter方法)。测试对象和依赖之间应该基于接口注入(blacklistprovider,whiterlistprovider和uicserive都是接口)而不是基于实现注入。这样在whitelistprovider还没有提供实现时我们就能为accesscontrolservice写单测。

实际上依赖注入这个概念最早提出很大程度上就是为了提高可测试性,从这个角度说,一个"容易测试的"代码一定是耦合较少的代码,如果一个被测试的对象需要注入十几二十个依赖,通常意味着bad smell,需要重构(builder/factory例外)

并不需要mock所有的依赖,比如上例的blacklistprovider提供了一个本地实现,在测试用例中就直接使用了这个本地实现,而没有去mock。一般来说,有本地实现尽量用本地实现,本地实现更能暴露问题,只有对外部依赖才必须使用mock。

以上介绍了单测的理念和方法,下面介绍一些通常对单测的理解误区。

经常听到开发汇报说"xxx功能已经写完了,今天的工作是补些单测"。愿意补单测是很有责任心的表现,但还是要说 单测应该随着代码同时产生,而不应该是补出来的。

按照错误率恒定定律,错误的产生是客观存在的。一次性手写超过20行代码基本就会出错。当一段代码(一个类或者一个方法)刚被写出来的时候,开发对整个上下文非常清楚,要测试什么逻辑也很明确(再次强调 单测是白盒测试),这时候写单测速度最快,也最容易设计出高强度的单元测试。如果等一次产出n个类,上千行代码再去写单测,很多当时的上下文都已经遗忘了,而且惰性会使人面对大量工作时产生畏难情绪,这时写的单测质量就比较差了。至于为几个月甚至几年前的代码写单测,基本上除了大规模重构,是没人愿意去写的。

在测试前置这方面最激进的尝试是tdd (test driven development),其次是tfd (test first development),它们都要求单测在代码前完成。尽管这两个实践目前不是很流行,但还是推荐有兴趣的同学去尝试一下tdd,经过tdd熏陶的代码会自然的觉得单元测试是程序的一部分,对于这点理解也会更深。

这也是开发经常会说的话,尤其是没有写单测习惯的开发经常会说的话。然而这句话其实也是不对的,不考虑单测框架自身的学习成本,任何情况下写单测都只会降低整体交付时间

根据"错误率恒定定律"和"规模代价平方定律",因为单测可以在尽量小规模内发现问题,其实这是一个很自然的结论。再紧的项目都要有设计、编码、测试和发布这些环节,如果说项目紧不写单测,看起来编码阶段省了一些时间,但必然会在测试和线上花掉成倍甚至更多的时间来修复。

错误率是恒定的,需要的调试量也是固定的,用测试甚至线上环境调试并不能降低调试的量,只会降低调试效率。

这是混淆了单元测试和集成测试的边界。

单元测试是白盒测试,应该随着代码一起产出,一起修改。单元测试的目的是让程序员"擦干净自己的屁股",保证相对小的模块确实在按照设计目标工作。单元测试需要代码和程序同时变动,不要说qa,就是换个开发写单测都赶不上这个节奏(除非结对编程)。所以单元测试一定是开发的工作。

集成测试是黑盒测试,一般是端到端的测试,很大的工作量在维护上下游环境的兼容上。集成测试运行的频率也比单元测试低,这部分工作由qa来作还是可以接受的。

越是重要的项目,程序员越需要安全感。单元测试就是程序员的救生圈,在代码的海洋中为程序员提供安全感。有了单元测试的保障,程序员才有信心在约定时间内完成联调和发布,才敢对已有的程序作修改和重构而不担心引入新问题。

作为软件开发中投入产出比最高的实践,我们要更大力度的推广单元测试。让更多的程序员尝到它的好处,从而爱上它。