单元测试两三问

2019-07-02   出处:腾讯移动品质中心TMQ  作/译者:林凯杰  

撸码一时爽,一直撸一直爽!畅快地写代码是人生一大快事,想要解放自己,更多更快地写代码,就需要自动化能力来替代人工进行测试,谈到自动化,很容易想到单元测试、接口测试、功能测试、性能测试、安全测试等等,其中部分环节是常被忽略亦或是无法实施的,比如本章探讨的主题:单元测试。


一、什么是单元测试

单元测试(英语:UnitTesting)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。 -- 维基百科

可能对于大多数同学来说,单元测试意味着非常细微粒度的测试,通常指方法级别的测试。而纯粹的方法级别单元测试,在我们看来性价比并不高(以笔者实践案例来看,测试开发代码比约为3 : 1),如果我们将单元测试的概念更加泛化,我们可以做到更加有效的测试,这里的有效包含两部分:一是有效率,二是有效果。

单元指最小可测部件,这个定义并没有对部件的粒度进行明确的定义,它可以是一个方法,可以是一个类,也可以是一个模块功能。通常来说,方法的封装更聚焦于单步功能的实现,而业务逻辑需要依赖多个方法串连完成,假如只聚焦在方法的测试上,会缺失业务逻辑链路的校验,对过于简单的方法做覆盖又发现不了问题。所以基于精准测试分析,结合业务特征,以不同维度的单元测试用例覆盖,可以做到更少的用例覆盖更多场景分支,做到更为有效和高效。

以 chrome 的测试源码为例,其中约25%为功能性方法用例,其余75%为业务接口/集成测试用例,可见在 chrome 的自动化测试实现过程中,大部分也是围绕业务逻辑进行,而非单纯的方法级别单元测试。本文所探讨的对象,更多的也是与业务逻辑相关的可测单元。


二、为什么要做单元测试

Kent Beck,在其提出的极限编程(Extreme Programming,简称XP)中就使用单元测试作为质量保障的重要手段,也曾与设计模式四巨头之一ErichGamma 共同开发了 JUnit 用于单元测试,为后续xUnit 系列框架的发展打下了基础。

有趣的是,在 stackoverflow 上一则关于《单元测试应该做到多细》的问题探讨上,Kent Beck 的回复却出乎很多人的意料,他并不推崇一定都要做单元测试,而更倾向于只针对于容易出错、没有信心的部分代码做测试。

译:老板为我有效的代码支付薪酬,而不是测试,所以我的理念是在能达到的自信水平上做越少的测试越好(我觉得这种自信水平应该要高于行业内的标准,当然这也可能只是我的自大)。我对编码过程中通常都不会犯的一类错误(比如在构造方法中错误地赋值)不会进行测试,而更倾向于对那些有意义的错误进行测试,所以对于一些具有业务逻辑的复杂条件我会特别小心。当在一个团队中合作时,我会非常小心地修改我的策略,以便测试那些容易让团队出现错误的地方。

笔者认为,高质量的代码取决于设计编码的过程,测试仅是质量保障的最后一环,能找出程序的问题,并不能提升代码本身的质量,当对于自己的编码有足够的信心时,我们甚至可以不用进行测试。当前测试环节之所以被认为必须,很大的一个原因就是因为不自信,害怕实现与需求不一致,害怕对于改动的影响评估不到位,希望能有一个靠谱的反馈,在代码改动时能告诉自己影响是什么,是不是符合需求,会不会导致历史功能受影响。所以在功能实现后希望测试同学介入,用大量的黑盒/灰盒/白盒 测试手段来验证代码修改。

但对于测试同学来说,黑盒的测试充斥着重复验证及不确定性,白盒虽然更具针对性但存在很高的理解分析成本,不管是何种测试验证方式,都已经是事后验收,存在着较长的验收周期和较高的修正成本。就像砌楼一样,如果等到墙体都已经砌好,再拉线检查是否垂直,虽然可以发现倾斜做出修正,但始终不如最开始就把验收的垂线拉好来得方便。

使用单元测试作为研发前置环节,有如下的收益: (参考MBALib)

  • 单元测试是一种验证行为

作为测试验证程序中每一项功能的正确性,高效率且可重复,涵盖了当前功能的验收点,不仅能在增量需求中验证编码的一致性,也能在后续迭代中评估对于历史功能的影响,更为代码重构提供了保障。

  • 单元测试是一种设计行为

使用TDD测试驱动,编写单元测试将验收点实现的过程,使我们从调用者角度进行观察和思考,可以将程序往易调用、可测试的方向设计,降低代码的耦合度,减少测试实现成本,同时使研发人员在编码时产生预测试,将程序的缺陷降低到最小。

  • 单元测试是一种文档行为

编码过程同步编写注释是一种良好的习惯,也是作为后续代码可读性可维护性的重要手段之一,但在项目过程中常常因为工期紧张没有执行到位。相较于注释,单元测试可以是另一种文档行为,它展示了方法或者类如何使用,有何业务策略和预期,从验收路径上解释了代码的行为过程,且是可编译、可运行、可验证的。

  • 单元测试是一种回归行为

在编码过程中,同步进行单元测试代码的更新,在后续任意的代码变更时,都可以即时高效地进行回归验证,使研发人员得到快速的修改反馈,且可以与持续集成交付流程结合,在高效的交付流程中发挥更大的作用。


三、为什么单元测试写不起来

单元测试在不少项目中其实都有所尝试,但鲜有坚持下来的案例,不管是测试或者是开发做这个事情,都存在着这样的情况:一开始写的时候很认真,当业务需求扑面而来的时候,常因为工期紧张,就开始搁置,加上随着用例数增多,稳定性差降低,维护成本变高,与发现问题收益不成正比,进展就越发地缓慢,到最后放弃单元测试的建设。对于这样的过程,也常常会存在疑问:为什么单元测试写不起来?

  • 测试负责单测

在实际的项目实践中,由于未验证单测可行性,通常会由测试角色负责进行实践,由测试负责此项工作成本高而收效甚微。追溯起来有些客观原因存在,国内绝大多数的研发流程都是产品需求 -> 开发实现 -> 测试验证,各角色之间的分工界线明显,开发只管实现,所有测试工作由测试承担,于是这里就有个看似非常纠结的问题:单元测试应该由谁来写?

可能会有部分开发同学这时候会想,测试不属于开发的工作范围,当然是测试来写了,也可能有部分测试同学会想,如果测试都让开发来写用例和验证了,那我们干什么呢,这不砸自己饭碗么?嗯,我也一样有过这样的困惑,如果这个问题换作是“测试工作应该由谁来负责”,那毫无疑问,是测试同学应该负责的工作。但如果仅是对于单元测试而言的话,笔者比较倾向于由开发来负责。

先抛开责任归属的问题,我们来看单元测试由谁来进行更加合适。首先,在分类上属于白盒测试,需要对于目标代码的设计实现有足够的了解,基于对内部结构逻辑熟悉的情况下进行分支场景的覆盖,这个环节上开发自然是对代码了解最权威的人,如果由测试人员进行,势必存在熟悉的成本及理解不充分带来的风险;其次,如上文所说,单元测试应该前置,不仅只是对于功能点的验证,更能指导编码实现在设计上的优化,涉及到项目技术方案和编码修改,当然是开发同学需要考虑的范畴。

新的研发模式变革追求更高效的研发过程,高度自动化能力成为快速验证的必要手段,对自己的代码质量负责已是开发人员职责所在,后置的测试只能起到辅助作用,开发才是质量保障的主体,软件的质量不是测试出来的,而是设计和维护出来的,就像工匠们在一点点雕琢他们的作品一样。

  • 单测意识缺失

那么,为什么开发同学不做单元测试呢?是和上文提及的一样,因为对自己的代码已经有足够的信心么?又或者,是因为并没有做单元测试的自驱力呢?单测的质量保障意识,往大了说,也许需要企业文化的引导,可能当前距离我们还有些遥远,它应当成为一种习惯,成为编码过程中无意识的存在。就当下而言更多的应该是开发还没感受到单元测试带来的好处,缺失单测的意识和动力吧,如果做一个事情有足够的收益和成就感,何乐而不为,亦或是被动地对未知事物进行作业,又何来兴趣动力之谈。

养成单元测试的习惯和意识并非一朝一夕的事情,需要有彻底投入的决心,应该朝着投入越多越有效果越是投入的正向循环发展,如果只是一小段时间应付式地尝试推进,很容易陷入为了数据而做,被其他事务打断,效果不明显投入变少甚至放弃的困境。

  • 投入时间不足

这大概是开发同学在进行其他事务尝试上最大的阻碍了,很多时候,并不是他们不愿意做,而是在需求实现上投入的时间真的特别多,当其他事务与需求冲突的时候,往往优先选择的还是需求,加班赶工,匆匆实现完后测试上线。

在这个过程,时间成本的聚焦很多时候都只看到了研发环节的投入,极致地追求快速实现减少研发环节耗时,而忽略了测试、返工、代码修正的时耗。实现环节的快速就等于快速了吗?换个角度,如果从整个需求的交付周期来看,情况可能不是这样。快速的开发实现,可能在设计与维护性上的投入不够,代码耦合性高,导致问题修复引入新的问题、需求变更成本高、重复调试测试,最终在验收及修复环节花费大量时间,拉长整个需求交付周期;而最开始引入单元测试,虽然在编程环节会有时间成本增加,却带来了良好的设计与快速验证能力,在源头上提升了代码的质量,减少后续各环节的投入时间,最终在交付周期上可能会更短,也为持续交付的快速自动化验证能力提供了可能。

  • 历史包袱沉重

项目经历了很长时间的需求堆叠,已有的框架设计起初并没有考虑可测性,做单元测试涉及项目架构的设计变更较大,且历史代码没有对应的单元测试建设,梳理及用例编写成本高。确实对已有项目的改造并非朝夕的事情,建议可以从四方面逐步来实现:1)与历史功能相比,优先增量代码进行单元测试编写,保证新加入的代码都能得到验证;2)对于新需求实现过程修改旧模块代码部分,进行单元测试编写,逐步覆盖公共模块代码;3)对于每一个发现的BUG,修正后都添加对应的单元测试用例,确保同样的问题不会再次出现;4)进行小模块重构,直至最后整个项目完成改造。


四、好的单元测试有什么特征

提及优雅的代码,不由得想起一个反面案例《给2500万行代码修复bug的程序员都怎么上班?》:千万级代码、百万级用例,一次代码提交,一天测试运行时间,千百次用例失败,问题定位无从下手,在猜测定位、修复尝试、测试等待、用例失败之间反复煎熬。规范缺失、运行耗时、不可维护,随着数量的增长,最后变得小心翼翼,不敢动弹,简直就是灾难性的结局。反之,建设好单元测试,应该考虑以下几个方面:

  • 独立性与程序分功能模块设计一样,单元测试用例在设计之初就带有较明显的测试意图,仅为保障某个可测单元功能正常,对于单个测试用例来说,更应该聚焦于要验证的特定分支场景,讲究的是一个“专”字,这样在验证失败的时候,可以非常明确地评估影响范围,同时又能很快地定位到问题所在。单元测试用例与验证的功能代码保持一致性,其他功能用例的修改不应该对其产生影响,测试结果也与用例运行顺序无关。

  • 全面性对既定的需求进行实现的时候,我们常常会先构思正常的业务流程链路进行实现,再补充处理各种异常逻辑,做测试的目的是为了保障模块功能的正确性,当然不能仅对主链路进行验证,也需要对异常分支进行保障(特别是一些中断式异常场景常常会是我们忽略的地方),聚合分支场景验证“分”的能力,形成功能质量“全”的保障,才更能增加我们对于代码质量的信心。

  • 快速性单元测试的应用场景在于研发实现和修改代码过程,给予快速的验证和反馈,所以对于测试效率上有较高的要求,需要用例运行起来很快,才能保障开发修改调试过程的连续性。另一方面,在保障开发代码质量的同时,对于测试的代码质量也存在要求,单元测试用例编写也是一种开发工作,存在开发和维护成本,大量重复或者结构相似的用例是不可取的,需要运用封装设计来减少重复的测试代码,让测试用例编写更快,成本更低。

  • 可预期性没有任何断言验证的用例永远不会失败,但也没有任何意义,每一个单元测试,必定带有明确的验证目的,其输入与断言都应该是明确可预期的。对于存在外部依赖的调用,可以使用MOCK等手段确保输入数据符合场景预期,对于输出预期,不管用例顺序变更,或者运行多次,也都应该是一样的结果。做到在确保输入预期一致的情况下,如果用例失败,那就是程序中存在BUG。

  • 可维护性考虑作为持续回归能力沿用,必然需要考虑其可维护性。编码规范统一能让不同人员相互理解测试用例,提升代码可读性让单元测试像文档一样易懂传承;功能封装统一能减少重复代码以提升代码的可重用性、可扩展性,减少后期修改成本;用例管理统一以便快速新增废弃用例,根据策略生成不同大小的用例集,满足不同验证场景所需。


五、什么代码适合做单元测试

高质量高效率是我们追求的目标,而质量和效率似乎一直以来都呈负相关性,在两者发生冲突的时候,往往我们更优先保证的还是质量。为了更全面地覆盖场景质量,也许意味着更多用例的编写,自然编写的成本会增加,运行的时间会变长。那么,是不是所有的代码都适合做单元测试呢?我们来看看自动化成本和价值的关系。

如图,我们可以这么理解:横坐标为代码对外部环境的依赖性,依赖越高,自动化实现的难度大、成本高;纵坐标为代码本身的复杂度,复杂度越高代码越容易出错,可能存在的问题越多,测试的必要性和价值越大。

  • 依赖很少的简单代码

对外部依赖很少,代码本身实现也比较简单,做单元测试的难度低、成本低,但也存在部分代码可能因为过于简单而没有测试的价值(比如构造方法、get、set方法等)。是否要进行自动化覆盖,可以根据测试人力和目标而定,追求高覆盖率可以进行覆盖,人力吃紧的时候可以选择不进行自动化实现。

  • 依赖较多的简单代码

对外部依赖很多,意味着自动化实现过程中,对于MOCK和HOOK的使用会变多,数据和场景分支伪造的成本变高,实现难度大,而本身代码又比较简单,出现问题的分支也不多,不具备有重构的价值,这部分代码实现自动化的成本会远大于发现问题的收益,建议不进行覆盖。

  • 依赖很少的复杂代码

复杂的代码容易出错,具备测试的必要性和价值,如果代码本身也存在比较少的外部依赖,比如算法、决策模型等有着明确输入输出可做校验的,写自动化的成本也低,这种就是非常适合做自动化的模块了,成本低,收益高,有多少做多少。

  • 依赖很多的复杂代码

这种代码可能是最不愿意看到情况,依赖多自动化成本高,代码复杂又容易出错,做自动化吧可能写用例的时间都远高于写代码的时间,不做自动化吧又难以保证这里的质量。对于此种代码,建议进行设计分离以提升可测性,比如单独将依赖处理部分解耦出来,仅对剩余的复杂少依赖部分进行自动化覆盖。

外部依赖是做单元测试中成本高低的重要影响因素,在开发设计的过程中,需要考虑因此带来的可测成本问题,对于测试来说,可以用 MOCK 就不要用 HOOK(难度高稳定性差),当然连MOCK都不需要使用是最好的。如果说一定需要用到外部依赖,那么依赖注入可能是一个不错的选择,其核心的原则是:依赖的对象不要在实现过程中创建,而是通过构造方法、方法参数或者暴露set等方法将对象进行传递,这样可以比较方便地使用MOCK的能力进行外部依赖模拟切断。至于使用MOCK进行自动化测试,也有同学会质疑是否合理(非真实环境测试、为可测性提升修改原技术方案成本),这点上同样也在探索过程,没有标准,可以根据项目认可度进行选择,在这里暂不展开讨论。

后记:实际单元测试实践过程可能遇到的问题还有很多,在这里笔者也只是抛砖引玉,希望有想法有经历的同学可以一起探讨,在实践路上多创造少踩坑,把自动化测试能力真正应用到项目研发流程,在保证代码质量的前提下,提升研发效率,缩减需求交付周期。


欢迎给测试窝投稿或参与内容翻译工作,请邮件至editors@testwo.com。也欢迎大家通过新浪微博(@测试窝)或微信公众号(测试窝)关注我们,并与我们的编辑和其他窝友交流。
179°|1792 人阅读|0 条评论

登录 后发表评论