我们为什么需要单元测试?
01 前言
刹车是降低了车速还是提升了车速?**我们通常认为写单测费力耗时、耽误研发进度,仿佛在给项目“踩刹车”。⼤家不妨带着这个问题往下看,详细聊聊为什么单元测试可以让软件开发跑得更快。
1.1 什么是单元测试
大家对于单测应该并不陌生,截取⼀段维基百科的定义帮大家唤醒⼀下记忆:
在计算机编程中,单元测试(Unit Testing)⼜称为模块测试,是针对程序模块(软件设计的最小单位)来进⾏正确性检验的测试工作。
单元测试的理念其实⼀直是编程的⼀部分。我们第⼀次编写计算机程序时,肯定会输⼊⼀些样本数据,查看其是否按照你的期望执行。如果结果不符合预期,你肯定在代码⾥穿插过⼤量的System.out.println,确保每个原子节点都符合预期。这个过程其实就是把复杂问题拆解成原子化的问题、逐⼀攻破的过程。单元测试的目的也⼀样,是**保障软件程序中每个最小单位的正确性,从而保障由最小单位构建起来的复杂系统的正确性。
深入展开单元测试的必要性之前,我们先去考考古,看⼀下测试体系是如何演进的。
1.2 测试体系的演进
过去的很长⼀段时间⾥,软件测试大量依赖人工检测。软件测试甚至是⼀个独立的工种(QA、Tester),QA/tester的日常任务就是进行大量的手工测试、繁琐易错。
⾃2000年代初以来,软件行业的测试方法已经发⽣了巨大的变化。为了应对现代软件系统的规模和复杂性,业界演变出了开发⼈员驱动的自动化测试实践。我们终于可以摆脱手动测试的繁琐,用软件来测试软件。
但过去的实践仍然留下了深远的影响,软件测试还是⼀个独立的工种,过去的QA演进成了SDET(Software Developer Engineer in Test),我们虽然进化到会使用工具了,但我们还只是会用工具的猴子。为什么这么讲?因为这种研发/测试分离的模式本身就留下了很多问题。
当研发和测试是两个岗位时,交付的边界是软件整体的功能性(functional requirements)和可⽤性。研发只要保证软件整体上功能完备、可用就行,测试也会聚焦在集成测试和端到端测试上。
但软件是由⽆数个最小单位构成的,在这种体系下人们会忽视最小单位的质量、是否可读可测可演进,最终难免“金玉其外,败絮其中”。
基于种种弊端,谷歌、微软这些对研发质量⾮常重视的公司都在从SDET的2.0时代过渡到 all-in-one 的3.0时代:微软在2015年去掉SDET工种,在陆奇带领的Bing中率先提出“combined engineering” 的概念;谷歌也将SETI替换成EngProd(Engineering Productivity),专门负责测试平台和工具的搭建,不负责具体的业务逻辑测试。
02
为什么需要单元测试
在如今的互联网时代,软件迭代的速度越来越快,研发的职责也越来越多。DevOps的理念是"you build it, you run it",研发/测试合二为一的趋势也可以理解为对"you build it, you test it"的呼吁。当研发要对自己写的代码质量和测试负责的时候,好的测试实践就必不可少了。
2.1 测试金字塔
就像盖楼需要从打地基、竖钢筋、灌⽔泥层层往上构建⼀样,测试也有类似的测试金字塔架构。下图出自《Software Engineering at Google》的测试章节,总结了Google在测试方面的最佳实践。我们可以看到测试金字塔由三层构成,最底层就是单元测试、占比80%,是软件系统的地基。
再往上是集成测试和端到端测试,分别占15%和5%。因为从下往上占比逐层缩减,因此被称为测试金字塔(跟盖⾼楼⼀样)。⾕歌推荐的这个比例是多年实践出来的结果,意在提升研发的效率(productivity)并提升对产品的信心(product confidence)。
测试金字塔的核心理念之⼀就是“Unit Test First“,每个软件项目⾥的第一行测试应该是单测(TDD甚⾄认为第⼀⾏代码就应该是单测),⽽且一个项目里占比最高的测试也应该是单测。
图片来源:Software Engineering at Google
2.2 优秀的软件离不开单元测试
为什么业界都把单元测试放在这么重要的位置?“抓大放小”,只写端到端测试不香吗?这里我们来展开讲讲单测的好处。
提升debug效率
单元测试是软件⼯程极佳的地基,因为它们快速、稳定,并且极大地缩小了问题范围,提升故障诊断的效率。
- 测试更快:单测没有其他外部依赖,跑的快,可以提供更快的反馈环,更快的发现并修复问题。
- 测试更稳定:同样因为0依赖,单测相⽐于其他类型的测试更稳定,不会受外部其他模块的不兼容变更影响。因此单测也是最能带给开发者信心的测试类型。
- 问题更容易定位:单测以最小软件单位为边界,出了问题可以缩小定位范围。相比之下,越是金字塔上层的测试类型,定位问题的困难度越⼤。复杂的端到端测试涉及众多的模块,需要⼀⼀排查定位问题。
提升代码质量
代码是写给⼈看的,好的代码应该是易读、易改、易维护的。写单测的过程其实就是吃自己代码狗粮(dogfood)的过程,从用户/研发视角去使用自己的代码,帮助我们提升代码质量。
-
好的代码是易测的:业界很早就提出了圈复杂度(Cyclomatic complexity)的概念,⽤来衡量⼀个模块判定结构的复杂程度,其数量上表现为独立路径的条数,也可理解为覆盖所有的可能情况最少使用的测试用例个数。圈复杂度⼤说明程序代码的判断逻辑复杂,可能质量低,且难于测试和维护。因此好的代码⼀定是圈复杂度低的,也是易于测试的。
-
易于迭代演进:没有什么软件是⼀成不变的,好的软件系统应该是易于演进的。单测覆盖高的项目模块更原子化,边界更清晰,修改起来更容易。单测覆盖更全的项⽬重构的风险也相对更小,相反⼀个没有单测覆盖的复杂项目是没人敢碰的。
-
更优质的设计:前面也提到,好的单测能够提升代码的质量。如果⼀个研发需要给自己的代码写单测,他就会注重代码的模块化分割,减少过长、圈复杂度过高的method。下⾯的例⼦就是⼀段没有单测的代码的认知复杂度值(可以理解是圈复杂度的⼀个改良版,从代码是否容易理解的角度衡量),超标了足足三倍。现在回过头来想补单测,脑袋都⼤。
提升总体研发效率
磨刀不误砍柴工,高质量、完善的单测可以提升研发质量和效率,加快项目⽬总体交付速度。**这句话乍⼀看是反常识的,写单测往往比写实现逻辑要更耗时,怎么还能提高效率?这也是⼤家不写单测最常见的理由:“项目赶进度,来不及写单测”。
如果我们的项目生命周期是以月计算的,写个原型很快就下线了,那写单测的确ROI不高。**但阿里有很多to B的业务,提供给用户的能力都是以年计算生命周期的,高质量代码的ROI随着时间推移会越来越高**,具体体现在以下⽅面:
-
减少debug时间:上面提到种种提升debug效率的原因,这⾥不再重复。一方面更高的单测覆盖可以节省debug所花费的时间,另⼀方面有充足测试覆盖的项⽬本身bug数量就会更少。举个现实中的例⼦:某团队由于历史上欠的种种债务,基本全靠端到端测试,毫无单元测试覆盖。造成的后果也非常严重,团队oncall的同学 > 50%的时间都是在修复各种奇怪的bug,没法投⼊宝贵的精力到架构升级等长期更重要的项目上。
-
增加代码变更的信心:前面提到没有测试覆盖的代码没人敢碰,有充足单测覆盖的代码可以显著提升改造代码的信心和意愿。再给大家举个例子:我加入阿里之前在Google总部工作过将近十年。如果你在Google工作过就会发现,你的代码经常会收到毫不相关团队成员发起的code change。⼤多数情况下这些都是同学们⾃发的去做大面积重构(mass refactor),比如看你的Java代码没有用Builder模式,就会帮你做个重构(Google⾥有⼤量自动化工具简化这些重构工作)。我们抛开主观意愿不谈,如果是没有测试覆盖的代码、还是毫不相关组的,你敢这么重构吗?我们都希望能有像⾕歌那样整洁的代码,**但没人敢碰的代码怎么变得更好?
-
提升代码自解释性:文档能够提升代码的自解释性,让研发效率更⾼。好的单测其实也可以被看作代码的⽂档,通过读测试就能快速理解代码的作用(参见TDD)。单测作为⽂档同时还完美的解决了文档保鲜的难题,给开发者提供了⼀套高质量、随着代码不断更新的⽂档。
-
更高效的code review:不是所有的问题和设计上的缺陷都能通过静态检查发现,这也是为什么需要人工code review作为代码质量的最后⼀道防线。在Google,代码评审是代码合并最重要的⼀个环节,因此评审的效率直接影响总体的研发效率。好的单测覆盖能够减轻评审人的负担,让他们把精力投入到更重要的部分(比如代码设计)。
-
更频繁的发版:敏捷开发倡导的持续集成、持续部署的前提就是全面、高质量的自动化测试。敏捷开发对于研发的提效就不多展开了。但光是能够更快速的发版本身就已经非常有价值了。
03
反面模式和常见误区
上面提到了写单元测试的种种好处和业界的最佳实践。我们也列举⼀下常见的反面模式和误区,帮助大家更好的规避类似错误。
3.1 测试的反面模式(anti-pattern)
反面模式⼀:冰激凌筒模式
只关注用户视角的端到端测试、大量依赖QA测试都会产⽣如下图所示的反面模式。很不幸,这也是在过去的测试体系影响下最常见的模式。冰激凌筒模式下,测试套件通常运行缓慢、不可靠、难以使用。缺失底层的单测也会让项目变得非常难维护,很难做大的改动。
图片来源:Software Engineering at Google
反面模式二:沙漏模式
沙漏模式下,项目中有大量的单元测试和端到端测试,但缺乏集成测试。虽然它不像冰激凌筒那么糟糕,但仍会导致许多端到端测试失败,这些失败本可以通过⼀套中等范围的测试更快更容易地捕捉到。当模块间紧密耦合,使得依赖项很难单独实例化出来的时候,就会出现沙漏模式。
图片来源:Software Engineering at Google
3.2 测试的常见误区
常见误区一:用户第一,测试覆盖用户的需求足够了
这个误区下会认为,端到端测试是站在用户视角做测试,把用户要的功能点都覆盖到就足够了。这种误区导致的结果就是冰激凌筒反面模式。虽然软件交付的最终功能是给客户使用的,但构成软件的代码本身是给人(研发)读的、需要⼈去维护。外部用户是人,内部用户也是人。
常见误区二:All-in端到端测试,节省了80%的测试代码量,赢麻了
从短期来看,不写单测可以节省80%的测试代码量和⾄少50%的研发时间。但只要项目复杂起来,时间线拉长,过去欠的历史债务(technical debt)早晚要加倍奉还。等到真正需要还债的时候再去补,可能为时已晚。
常见误区三:写单测的人都弱爆了,我长这么大还没写出过bug
这篇文章可能不适合你。不过软件开发是个团队项目,你写的代码最终也会落到别⼈⼿里去升级维护,没有测试覆盖的代码是没⼈敢碰的。
04 总结
结尾处再快速总结⼀下。本⽂从测试体系的历史入⼿,讲述了从手动测试 -> 靠别人自动化测试 -> 靠自己自动化测试的历史演化进程,也尝试着从这个视角解释为什么⼤家过去不重视单元测试。之后我们分别讲述了什么是单元测试,业界的金字塔测试最佳实践,并且深入讲解了单元测试的种种好处。最后我们列举了常见的反面模式和误区,帮助大家快速识别规避常见的错误。
如果把测试体系的演进类比为人类的进化,那么我认为无单测覆盖和有充分单测覆盖的软件就好比爬行的古猿和直立行走的现代人类。由衷希望大家能够重视单元测试、写好单元测试,让我们的软件尽快从爬行进化成奔跑,迸发出源源不断的生命力、创造出更多价值!
参考资料
[01] https://abseil.io/resources/swe-book/html/ch11.html
[02] https://testing.googleblog.com/2015/04/just-say-no-to-more-end-to-end-tests.html
[05] https://blog.testproject.io/2018/11/06/the-software-engineer-in-test/