对于单元测试的价值,相信大家都很认可(至少从政治正确的角度很认可),但具体执行起来却很难,常见的障碍有两个:
- 延长了开发时间——本来工期就紧,加上单元测试,岂不雪上加霜;
- 项目代码基于各种框架(如Spring)编写,并依赖外部环境(数据库、外部服务接口等),导致单元测试很难编写;
第一个问题的本质是单元测试的成本收益问题,第二问题是项目架构的可测性问题;这两个问题是确确实实存在的,如果不能妥善解决,就在项目组强制推行单元测试,必然一地鸡毛。
1、单元测试的成本与收益
编写单元测试确确实实会引入一定的开发成本,所以问题就变成:单元测试的成本如何?好处在哪,能否平衡掉成本?
1.1 收益
如果没有收益或收益太小,根本没有讨论成本的必要,所以我们先讨论收益。
其实,关于单元测试的好处,已经有太多的书籍或文章讨论过了,极限开发模式甚至倡导“测试先行”;本人仅就自己的体会来阐述一二。
提前验证代码逻辑
这是不言而喻的,编写代码是一种正向的思维模式,而编写单元测试强制开发者转换视角,重新审视自己的代码。据本人经验,对于一个新实现的功能模块,总能够通过单元测试找出一些问题;如果没有的单元测试,这些问题可能要等到联调阶段、黑盒测试阶段才被发现,修改的代价会成倍地增长。
仅就这一点,我们投入在单元测试上的成本就收回一大半了。
优化代码结构
这里有一个最基本的逻辑:能够编写单元测试,是良好代码架构的一个非常重要的特征。换句话说,编写单元测试的过程,就是一个整理、优化软件架构的过程。
为什么会这样呢?因为从本质上将,对一个业务模块而言,单元测试代码代表一种使用该模块的方式(另一种方式是正常的业务接口)。一个模块,要是能被两种方式很方便地使用,需要良好的结构、适当外部耦合性、足够灵活的接口。
换言之,提高代码的可测性,就是在优化软件架构。而且比起那些纯理论的软件设计原则,单元测试提供了一种实际可操作的检验手段。
降低代码修改及重构风险
我们都知道,代码维护的成本远远超过了第一次编写它的成本。我们在修改老代码的时候,最怕的是引入错误。单元测试不能给我们100%的保证,但能极大地降低这种风险。
修改代码的动机有两个:一是功能需求或环境的变化;二是重构。尤其就重构而言,这是开发团队的内部决策,如果引入严重的错误,估计产品团队会暴跳如雷;绝大多数情况下,我们放弃重构并不是没有重构方案,而是承担不了这个风险;而这种风险会随着时间累积,直到我们彻底放弃治疗。
如果有单元测试来保驾护航,我们就会更有信心对代码进行不断的重构,跳出恶性循环。
其他好处
上面三个好处还不够吗?如果这三条都不足以打动你,那么再说三十条也无济于事。
1.2 成本
天下没有免费的午餐,这么大的好处,没有一定的付出是不现实的。单元测试的成本往往会比你预想的大,这也是导致大家浅尝辄止的原因之一。就像买东西一样,商品的具体价格并不重要,与预期价格的差距更能影响购买决策。我们必须抛弃不切实际的幻想,单元测试是一项大投入大收益的技术投资。
测试代码是项目的一部分
首先,单测试代码不是二等公民,它是项目代码不可分割的一部分;测试代码的写法虽然不同,对它的质量要求丝毫不能低于业务代码。唯有如此,它才能在项目整个生命周期内被很好地维护,并持续创造价值。
所以漫不经心地编写单元测试的态度要不得,安排低水平的实习人员来编写单元测试也注定失败。单元测试必需由编写业务模块的人亲自操刀,该有的设计原则、代码风格都不能落下。
设计测试模块
既然测试代码也是项目的一部分,那么我们可以将测试代码看做项目的一个独立模块。既然如此,我们花些心思设计这个模块也就理所当然了。
于是乎,诸如测试工具封装,测试基类抽象,等常规设计手段都派上了用场。既然名分已正,该有的待遇就得跟上,不必遮遮掩掩。
扩展业务模块接口
为了方便单元测试,业务模块可能需要提供额外的接口。打个比方,从业务需求的角度来讲,某个数据可能是不需要删除的,但从测试需求的角度出发,就需要支持删除操作。 又或者,短期内并不需要考虑对不同环境的支持,但为了测试,就需要增加环境抽象。
首先,这是正常的,对于该增加的接口或抽象,我们不必担忧;但是如果为了测试,将整个软件结构都破坏了(注意是破坏了,而不是优化了),或将业务流程都搞得变形了,那就得不偿失。如何把握这个度,对于有一定设计功底的人来说,其实并不困难。
运行成本
剩下的就是运行单元测试的成本了,单元测试代码必需能够频繁地运行才能发挥它的价值。所以整个项目的单元测试运行时间,最好控制在10分钟以内,而且在开人员的工作电脑上就能运行。如果做不到,就调整或放弃部分单元测试。
1.3 对比成本收益
成本收益的评判,取决于具体项目和开发团队,只有试试才有结论。
2、项目代码的可测性
项目代码的可测性要求与良好架构设计的原则是一脉相承的,前者是后者的一个评价维度。
2.1 单元测试的目标
首先要搞清楚单元测试到底测什么?我的答案是测业务逻辑。什么是业务逻辑,就是那些需求文档里面的东西。单元测试不是用来测试底层平台适配,也不是测试数据库和redis,更不是测试第三方框架;尽管可能有必要为这些目标编写专门的测试代码,但我认为这不是单元测试的范畴。
这个限定可能会引起一定争议,有不同的看法很正常,项目组自己考虑清楚就好。
2.2 耦合性要求
从单元测试目标出发,要求我们的业务逻辑代码和外部环境保持较低的耦合性,尽量不要对具体外部环境因素有较强的依赖。(这里的外部环境,包括技术框架、操作系统类型、网络、外部服务接口…)。这样,如果在单元测试的上下文中,某些环境因素不能提供所需的服务,我们可以通过手段来模拟一个虚假的、可操控的实现。
单元测试领域有很多mock技术框架,就是用来模拟外部接口的。
2.3 不适用单元测试
如果一个项目或模块的业务逻辑很单薄,那单元测试就没啥意义,再怎么调整架构也没用。打个比方,转发网络消息的代理软件,或是负责输出日志的模块,根本就没什么复杂的业务逻辑,这样的软件模块需要的是压力测试,而不是单元测试。
3、编写单元测试的技巧
所谓技巧,都是特定于具体场景的把戏,下面所列都是我们项目组总结的,仅供围观者参考。
3.1 搞一个单元测试的全局标记
这个全局标记,告诉业务模块:“你当前运行运行在单元测试的模式下,请调整那些些与业务逻辑无关的运行策略,以配合单元测试”。
我们的项目用maven管理,实现这个全局标记比较方便,方式如下:
- 在test/resources下面放一个unittest.property文件;
- unittest.property包含一个类似unitest=1的属性定义;
- 项目启动时,读取这个属性,并放到一个全局变量里
接下来,我们就通过这个全局配置来搞事情。
这个机制对业务代码有一定侵入性,还是那句话,掌握好度。
3.2 排除多线程的影响
单元测试代码是单线程运行的,如果业务逻辑的执行是多线程的话,那就麻烦了。所以单元测试的模式下,应该把多线程变成单线程。从原则上讲,线程模式是一个执行细节,不应该与业务代码耦合,如果你的项目做不到这一点的话,请调整。
3.3 暴露内部状态数据
单元测试是白盒测试,它需要知道更多内部状态才能判定业务逻辑是否正确,而正常的业务接口可能没有携带这些数据。
先尝试将单元测试所需的数据添加到接口返回中,如果这样并不影响安全性,也不破坏代码设计和接口抽象,那就是OK的。
如果这样不行,我们的解决方案是,搞一个全局的UnitTestContext(本质上就是一个hashMap),业务逻辑代码在单元测试模式下,会写入一些内部数据到这个Context,给单元测试代码来读取。
打个比方,我们项目中有些逻辑涉及概率计算(比如,不同的用户有不同的抽奖概率),单纯通过接口返回很难判定概率是否正确。为了解决这个难题,我们在执行概率算法之前,将概率值写入UnitTestContext。
3.4 绕过技术框架
如果一个技术框架对单元测试造成阻碍,那么单元测试就要绕过它。绕过它有两种方式:一是当它不存在,比如网络框架,我们可以直接构造解析好的请求参数,来测试Controller;二是mock一个替代品。
mock手段会大大增加单元测试的编写成本和复杂度,只有不得已而为之。
对于redis和mysql数据库这种底层机制,它足够稳定,也提供了足够灵活的操作接口,并不会阻碍单元测试,我们可以把它当做基础设施来看待,不必绕过它。
3.5 基于UserCase来设计TestCase
单元测试应该验证UserCase,换句话说,我们应该从模块的功能角度来设计单元测试的TestCase,而不是从实现细节角度。我不赞成“每个类都应该有对应单元测试”的观点,这样会导致单元测试和功能模块深度耦合。
如果单元测试代码应该被视作项目的另一个模块,那么它理应和其他模块保持适当的耦合性。
4、提高可测性
如果将单元测试的测试目标定义为“业务逻辑”,那么UI界面就与单元测试无缘,外部的第三方服务也如此。
项目中那些“非业务逻辑”部分,都不是单元测试的菜,但并不意味着我们完全放弃提高这些部分“可测性”的努力。所谓“可测性”,可不限于单元测试,如果能够让手工测试更加容易一些,其价值也不可小觑。
4.1 业务逻辑在哪?
以互联网产品为例,业务逻辑可能在服务端也有可能在客户端,甚至客户端和服务端各占一部分。
- 第一类:业务逻辑在服务端
这是最常见的场景,业务状态完全在服务端,客户端即使缓存部分状态,也不过是为了在网络不佳时提高用户体验。对于这样的场景,我们不防更进一步,在项目组建立一个规约:客户端不要编写任何业务规则。 这样,对于业务规则,我们只要专注于测试服务端即可。
- 第二类:业务逻辑在客户端
最典型的是单机游戏或应用,玩法逻辑全在客户端,服务端仅有的业务就是存个档。此时,客户端工程就应该编写单元测试。
- 第三类:客户端和服务端各占一部分
这种情况是比较棘手的,多人在线的网络游戏中较常见,一方面从安全性、一致性考虑,必需将核心业务逻辑放在服务端,另一方面,从性能和用户体验考虑,客户端必需具备同步计算的能力。所以,客户端和服务端不仅都包含了业务逻辑,可能还有重叠部分,测试难度相当之大。
上面的描述只是一种定性的分类,实际情况不可能这么绝对。
好在,大多数互联网产品归属第一类,即使是此类产品,也有一些安全性、一致性要求不高,且仅与交互有关的数据状态,放在客户端本地存储更加合适。
4.2 提高客户端的可测性
假设我们的项目是上述第一类互联网产品,这并不意味着客户端就很简单,它可能包含大量的交互逻辑,其中可能涉及丰富的UI界面、交互、动画、特效。这些交互逻辑的正确性对产品体验至关重要,既然无法用单元测试,我们就得想想其他办法。
交互逻辑总是被业务状态所驱动的,换句话说,特定的UI交互路径只有在特定的业务状体下才会出现。如果要某种业务状体很难复现出来,那么对应的交互逻辑就很难被触发,对这个交互逻辑的修改就会变得很困难。
如果一种业务场景很难复现,那么无论开发人员,还是测试人员,就不得不减少对它的测试,隐患就此埋下。
开发人员,一定想过种种办法来复现特定业务场景,比如,修改数据库,修改文件,编写临时代码…。这些手段并没有错,问题在于它是非正式的,不可复用的,没能提高软件自身的“可测性”。
4.3 调试功能是软件的一部分
我们并不需要引入什么新技术,而是要转变思维。我们要把那些“修改业务状态以复现业务场景”的功能当做软件整体的一个部分,这个部分的唯一特殊之处在于:对普通用户不可见。
这些功能,可以统称为“调试功能“,实现它们的代码则归到“调试模块”之下。和单元测试一样,“调试模块”也是值得我们投资的,绝不能马虎对待。
我们的做法
一开始,项目组为了这些调公功能编写了一组服务端Http接口,并编写了说明文档。在相当长的一段时间内,我们自认为这种方式工作良好,不过有几个小瑕疵还是挥之不去:
- 通过PostMan或浏览器来调用调试接口,稍显麻烦;
- 尽管有说明文档,非技术人员用起来还是不够顺利;
直到《Unix编程艺术》一书为我指点了迷津:既然我们有客户端APP,何必还要手动执行HTTP接口,把它们做到客户端里面不就行了。于是我们以编写正常业务功能的态度设计了一个功能丰富的调试面板,为此,客户端开发、后端开发甚至美术人员都投入了一定的精力。
总结
单元测试的目标是在软件的整个生命周期内,降低修改风险,为项目逻辑的正确性保驾护航,还能促进良好的项目架构。
不过,我们要认识到,单元测试不是易得的,是一项大投入大收益的技术投资。首先它对项目架构有一定的要求,架构混乱的系统是没法做单元测试的(一旦有了单元测试,反过来可以促进良好架构);其次,单元测试代码是项目的一等公民,应当被严肃地设计、编写、维护。
单元测试只适合测试业务逻辑,不适合测试UI。当单元测试不可行是,我们应退而求其次,通过其他手段来提高产品的可测性,比如:编写调试功能。
提高软件的可测性,不是一个技术问题,是价值观问题和态度问题。