前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >单元测试两三问

单元测试两三问

作者头像
腾讯移动品质中心TMQ
发布2019-07-03 16:33:41
1.1K0
发布2019-07-03 16:33:41
举报
撸码一时爽,一直撸一直爽!畅快地写代码是人生一大快事,想要解放自己,更多更快地写代码,就需要自动化能力来替代人工进行测试,谈到自动化,很容易想到单元测试、接口测试、功能测试、性能测试、安全测试等等,其中部分环节是常被忽略亦或是无法实施的,比如本章探讨的主题:单元测试。

一、什么是单元测试

单元测试(英语: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进行自动化测试,也有同学会质疑是否合理(非真实环境测试、为可测性提升修改原技术方案成本),这点上同样也在探索过程,没有标准,可以根据项目认可度进行选择,在这里暂不展开讨论。

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

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-07-02,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯移动品质中心TMQ 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
持续集成
CODING 持续集成(CODING Continuous Integration,CODING-CI)全面兼容 Jenkins 的持续集成服务,支持 Java、Python、NodeJS 等所有主流语言,并且支持 Docker 镜像的构建。图形化编排,高配集群多 Job 并行构建全面提速您的构建任务。支持主流的 Git 代码仓库,包括 CODING 代码托管、GitHub、GitLab 等。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档