分享

重新思考软件测试

 gfergfer 2023-11-10 发布于辽宁

虽然之前也写了快二十年代码了,对于很多现代软件工程的 buzz word 如敏捷,持续集成,DevOps 等都耳熟能详。但这两年在项目上碰到的各种挑战,以及跟一些开发者同僚交流下来发现,其实软件工程的很多最佳实践并没有被广泛接受和应用,在软件质量领域,我们持续受到了很大的挑战。

过去十多年,中国的互联网软件得到了蓬勃的发展,其中有两个看起来比较突出的挑战:一是如何能快速推出一个产品原型,迅速尝试一些新想法,抢占市场知名度;另一个是当产品赢得大量用户之后,随之而来对于底层基础设施的大规模计算执行,大数据量存储与处理方面的挑战。在这两个挑战下,形成了很多具有“互联网特色”的软件工程实践,例如早年很有名的 Facebook 的口号“Move fast and break things”,以及各种技术分享中最火热的一般都是基础系统架构的演进之类的话题。

这两年国内的 SaaS to B 领域逐渐开始升温,很多“传统”软件开发的挑战也随之而来,跟互联网类软件的问题还不太一样。例如很多 SaaS 软件的数据量没有那么大,对底层基础设施没有很高的要求,如果照搬“internet scale”的设计就很容易自己给自己添麻烦。商业软件的演进路线也相对比较明确,后续持续维护的时间一般也更长,所以碰到所谓 legacy system 的情况也会更常见。另外就是很多公司内部的业务需求与流程非常的复杂,由此而来对于软件“领域模型”的复杂度要求也上升了很多。

这些挑战即使是比较有经验的互联网软件开发者也不一定有积累的技能可以直接解决,也经常让我们感叹国内的软件人才为何如此稀缺。所以我们还是需要回到软件工程角度,去看看适合此类企业级软件开发的最佳实践到底是怎么样的。

现代软件工程

当年在计算机硬件飞速发展时,人们就发现与之配套的软件开发的增长速度却极其有限,经常碰到无法按时交付,超出预算,品质低下,不符合需求,难以维护等等问题。著名的《人月神话》也是在那个时代撰写出版的,描述了大型软件开发项目中所遇到的困境。

为了解决这个问题,人们非常自然的一个思路就是借鉴我们其它大型工程中已经应用过的实践,最典型的就是“瀑布式开发”模式。

图片

瀑布式开发模型

在这个模型中,每一个步骤有着严格的先后顺序之分,希望能严格把需求和设计确定好后再开始开发,然后测试验收后部署上线。这套方法在很多其它类型的工程里都得到了大量的应用,比如如果家里做过装修的话就会切身体验一遍这个流程。

瀑布式模型之所以有效,一个很重要的原因是它很好地解决了“实体化生产”带来的挑战。比如我们要做装修,把所有实体部件构建出来,像水管,电线,开关,地板,橱柜,家居,电器等等,需要花费非常大的代价。同理如果我们要做硬件的生产,如芯片,汽车等,批量化制造的成本也是非常高的。这些最终实体生产出来后,如果发现有问题,需要返工修改,几乎就代表了项目的失败。

但对于软件来说,其本身并不存在“实体化生产”的问题。我们知道任何一份代码写出来,做批量化的复制运行可以说是成本很低的。所以瀑布式模型后来受到很多诟病的原因也不难理解了,因为它想要解决的问题,在软件工程领域并不存在(至少不那么严重)。

所以我们在思考软件工程时,需要抵制住套用人类千年来的传统工程思维的影响,从软件本身的特性出发去寻找更加匹配的方法论。没有“实体化生产”的包袱,可以快速做修改迭代,需要服务于现实需求,但又会随着环境,技术的演变发生丰富而又难以预料的变化,是不是可以联想到一个有些类似的活动:科学研究。

跟软件一样,科学研究很多时候会在理论和设计层面进行推演,即使涉及到实验,一般也是相对来说较低成本的形式。科研的探索方向是明确的,但具体的路径和实现方式有着很大的不确定性,因此需要不断地提出假设,设计实验,进行实验,获取反馈,迭代认知,再提出新的假设这样循环往复。这与后续出现的“敏捷”开发模式有着很高的相似度。

图片

Agile 模型

所以当前主流的现代软件工程思想,就是把软件开发行为的本质理解为假设,行动,反馈,学习这几个步骤。大家应该也经常听到类似的说法,比如“架构是演进出来的,而不是设计出来的”。与科学研究不同的是,软件工程一般有更长的持续性,因此在设计与行动这块,会更加注重“控制软件熵(复杂度)”。这篇文章会更多从软件质量角度出发,来讨论软件工程中的反馈与学习。如果我们能做到高质量的反馈与学习,就能打造出更符合用户需求,功能完备性和质量都更好的软件产品。同时我们也会看到,反馈学习本身对于复杂度的控制也有很大的协同促进作用。

软件质量的综合指标

如何评价软件的质量呢?如果我们以传统眼光来看,一件物品的质量的衡量方式可能是其广大使用者在一定时间内遇到的故障问题数量和严重程度。但对于软件来说,质量其实是一个动态的过程,因为客户的诉求和使用场景会发生变化,而软件本身也会不断升级版本来满足相应的诉求。

在查阅了一些资料后发现,业界对于软件质量的定义主要集中在四个指标上:

  • 每次软件变更带来的问题数的平均值。
  • 每个软件问题从发现到修复所耗费时间的平均值。
  • 从提出需求,到软件交付所花费时间的平均值。
  • 发布/部署到生产环境的频率。

前两个指标主要是从稳定性的角度来看,我们发布软件的问题多不多。而后两个指标是从吞吐量角度来看,我们发布软件的速度快不快。

绝大多数软件工程师都会觉得,速度和稳定性这两点是个 trade-off。如果我希望发布更稳定,那么一般来说就需要花更长的时间做质量保障相关工作,例如各种测试验证。而如果用户急着要某个功能,那么必然会影响发布版本的质量,可能引入不少 bug。

但有意思的是,从《Accelerate[1]》这本书中我们可以看到来自 Puppet 与学术机构合作的“State of DevOps”报告中指出,具有更高发布稳定性的团队组织,往往其发布的吞吐量也会更高。而发布稳定性低的组织,可能因此引入了更多的流程,人工审核,或者引起了客户对其新版本的不信任,进一步拖慢了其发布的吞吐量。也就是说,这两者非但不是非此即彼,而是相互促进的,真正的 trade-off 是在“更好更快”与“更差更慢”这两者之间。

图片

这个分类是通过聚类算法跑出来的

从逻辑上来说也好理解,如果我们想有更多的学习与认知迭代,打造出更符合用户需求的软件,更快的发现问题并修复,那么就需要有更快的发布速度获取反馈。如果我们想要有更快的发布速度,那么本身的质量也需要过硬避免复杂的流程(书中也提到,流程通常对质量没什么帮助),发现问题要尽可能提早减少返工等。而且代码设计上需要更加合理,才便于做修改与演进,后面我们也会看到代码设计与可测试性之间也有着协同促进的关系。

所以还是回到前面所说的,为了提升软件质量,还是回到反馈学习这个核心上。而这其中最有意思的,莫过于测试了。传统的看法会把测试等价于做其它实体产品的“质量检测”环节。但在软件工程中,测试不仅仅只是保障软件没有问题的一个步骤,而是我们获取反馈,学习知识最重要的一项基础手段。如果没有高质量,即时的反馈,那我们就不得不在过程中带入更多的猜测,这在复杂的软件项目上往往是很不靠谱的。

就像在科学研究中,我们会通过实验来获取理论在真实世界中的反馈一样,测试就是软件开发中获取反馈的方式。如果以玩游戏来类比,做实验有点像回合制游戏,玩家会通过缜密的思考推演,来下一步棋,然后再等待对手的反馈。而软件中的测试,则是实时化的,我们指挥兵力前进过程中,能即时地感知到周围环境的变化,对手的动作反应等。如果网络卡了,你很可能就无法发挥出很好的水平,因为反馈速度变慢了。

同理我们在 IDE 里写代码时,都是有实时的静态代码分析与提示,告诉你这里可能写的有问题。进一步,我们也需要添加相关的测试来丰富我们的“感知”,实时地了解这里的改动可能会破坏某些已有功能。顺便一提很多人推崇vimemacs这样的工具,但从实时反馈,以及反馈信息的丰富程度上来说,IDE 应该是更好的选择,而且现在很多这类传统编辑器也在尝试通过各种插件在终端里去实现类似 IDE 的效果。

讲到这里大家应该可以理解为何测试与软件开发应该是一个整体了。在自动化测试这个重要的基础手段之上,后续才演化出更多的如持续集成,持续部署,更全面的 DevOps 等实践。

为何需要研发来写测试

前面我们已经提到测试与开发应该是一体的,而不应该独立开来。这个想法可能会受到很多传统工程思维或者旧行为模式习惯的挑战,例如:

  • 很多人潜意识中会觉得,工程师的任务就是写“功能代码”,写完就算任务完成了。但实际上,工程师的任务应该是解决客户问题,创造业务价值。任何对这个目标有帮助的事情,都应该放在我们的职责范围内,例如写测试,写文档,完善持续集成与交付等。
  • 为什么写自动化测试没有成为大家的习惯?一个常见的“陷阱”是,写完功能代码能跑通,会给人带来满足感;把功能上线后出了问题,我加班解决问题拯救公司,加倍带来满足感。这种短期满足感的叠加,反而让我们陷入一个恶性循环。
  • 还有一个常见的想法叫“速度与质量不可兼得”,因为排期紧,所以研发只能只写功能代码。但实际情况我们前面也提到了,这两者恰恰是高度正相关的。达到高质量软件的重要途径,是更快速的发布,以获得更及时的内外部反馈信息(测试是否通过,客户是否对功能满意);达到更快发布速度的路径,是高质量的软件,否则引入一堆的问题肯定会拖慢发布速度,或者用户根本不愿意升级。几乎没有公司是反过来的,质量很高,但迭代发布很慢,或者质量很差,但迭代发布很快。我们作为专业研发人员,在估算开发周期时就不应该有意把写代码和写测试分开来。
  • 还有一个常见的抱怨是,需求总是变,那我写测试有什么意义?这个问题也印证了我们前面所说的,软件开发与科学探索是具有一些相似性的。“唯一不变的就是变化”,对于绝大多数的真实世界问题来说,其复杂度和变化速度,都是很难以人类大脑来进行预先设计的,因此现代软件工程的核心其实也就集中在这两方面,“控制复杂度”和“反馈学习”。得益于软件易于改变的优势,我们得以应用敏捷模式,而不是其它现实世界工程中常用的瀑布模式。但这个思维的转变,其实需要很长时间才能逐渐成为大家的共识。
  • 为什么测试和功能开发应该同步进行,而不是先写功能,等全部完成后再写测试呢?作为开发者,我们产出功能代码的速度总体来说是稳定的,修改完成后,你获得测试反馈的延迟越长,则这段时间内你作出的修改就越多。是 1 秒钟就知道自己代码写得对不对,还是 1 周后才知道,这个差别是巨大的。如果 1 周后才发现有问题,那么是不是这一周里做的很多东西都有可能要返工了?这也是 TDD 之类的思想产生的原因之一。
  • 大家也经常提术业有专攻,如果自己测试自己写的代码,容易进入思维盲区。后面我们会详细讲到测试的分类,但是如果仅依赖传统质检步骤的端到端(e2e)测试,是有很多问题的。例如 e2e 测试的开发,执行与维护成本很高,需要覆盖的 case 数量更多,检查的粒度太粗,难以做变量控制等等。从获取反馈的质量角度来思考,e2e 测试也有不少缺陷,因为它把整个系统的复杂度都包含进去了。当出现 case 失败时,我们是不是还需要花更多的时间去定位问题,是数据问题吗,是环境配置的问题吗,是别的组件的问题吗,是网络抖动的问题吗,等等。而好的测试应该能非常精确的告诉我们出问题的地方在哪里,做好“变量控制”,使得测试本身也更加稳定和可信。
  • 软件工程还有一个核心诉求是“控制复杂度”,而通过写测试,我们会成为自己设计的类和 API 的第一使用者,能更早的发现设计上的问题,尽量规避“偶然复杂度”。如果发现不好写测试,那么很可能就是我们的设计有问题,可能是 API 不够清晰,可能是不够模块化,可能是没有遵循高内聚低耦合的原则等等,可以及时进行优化。如果是写完代码再写测试,那么很可能就会偏向 end to end 的粗粒度测试,而到了几个月后要修改功能或添加新功能时,你仍然会遇到修改困难,需要重构的问题。所以至少写单元测试,应该跟开发功能的是同一个人才更能发挥出这些好处。
  • 最后在团队协作层面,模块的开发者负责模块的测试也对于沟通成本的控制会有很大的好处。试想如果我们要造一辆汽车,所有的测试都要在整车拼装完成后在马路上进行,那样的话团队间的沟通成本,可能造成的返工风险,各个模块的可复用程度等都会大打折扣。仅仅依赖 e2e 测试,那么系统集成的开销和风险就会大幅上升。

综合这些点来看,要成为一名合格的现代软件工程师,自己的功能自己写测试是非常有必要的。且在绝大多数的先进公司,开源项目中,都已经成为了一种主流实践方式。

软件测试基本概念

前面聊了这么多,终于要来上点干货了。以下内容很多来自于《Software Engineering at Google[2]》,《Effective Software Testing[3]》,《Unit Testing[4]》这三本书。尤其是最后一本,是我从业以来看过最好的一本关于测试的技术书籍,值得强烈推荐。

什么是好的测试

写测试也是需要有很多深入思考的,并不是大家印象中的“周边工作”。相信很多人就没有深入想过,到底什么才是一个好的测试?为什么有些项目也写了很多测试,但好像经常挂掉也没人去看,维护又要花很多时间,线上的 bug 也没有多少被抓到,好像完全成了摆设?

为了找到好测试的标准,我们先来看一下测试的目标是什么。如果没有测试或者测试的质量很差,会导致随着项目的推进,开发迭代速度出现剧烈下降。这样的现象被称为软件熵,也即代表了软件的腐化。好的测试的目标是保证软件的可持续发展,在长期开发之后,依然可以持续演进。

图片

好的测试才能让项目持续可控

具体来说,测试的目标包括:

  • 与研发流程紧密集成,达到前面所说的“实时反馈”的效果。
  • 针对代码库中最重要的部分进行测试,一般就是领域模型。
  • 以最小的维护成本产出最大的价值。测试的维护成本包括:
    • 重构功能代码时,有时候也需要重构测试。
    • 每次代码变更时都需要执行测试,因此执行成本也需要考虑。
    • 测试报错时,需要进行排查和处理,尤其要注意误报。
    • 阅读和理解测试也需要投入时间。

如果仅仅只是增加测试用例的数量,并不能很好地实现控制软件熵的目标,同时也需要结合测试成本考虑总体的 ROI。一个好的自动化测试应该符合四条标准:

  • 防止代码功能失效(regression)。为了起到更好的保护作用,测试执行时应该考虑去尽可能覆盖更多的代码,项目中复杂度高或者对于业务逻辑重要的部分。
  • 对重构友好。测试应该对可观测的领域行为进行测试与验证,而不是对于实现细节。因为实现细节是很容易被重构影响的,在软件行为没有发生改变时,不应该需要对测试代码做修改,减少测试失败的误报。
  • 快速反馈。测试跑得越快,我们就越可以频繁执行,实时发现问题,降低修复成本。
  • 可维护性。包括是否容易理解测试代码,是否容易执行测试。

图片

提升测试的“准确率”

其中前两点分别对应了测试中的 false negative(测试没有报错,但其实有 bug)和 false positive(测试报错了,但其实没有 bug)这两类情况。对于成熟项目来说,因为测试的数量会非常大,重构的频率也会更高,所以控制 false positive 一般会更加重要。反复出现 false alarm,会很快让开发者丧失对测试的信任,从而失去其存在价值。

我们可以对上述四条标准打个分,而总体的测试价值是这四个得分的乘积。这意味着如果有任意一个维度得分为零,那么整体测试的价值就会迅速降到零。

是否存在完美的测试呢?其实上面提到的前三点之间,是有一些互斥性的,需要做一些权衡。举例来说:

  • 端到端测试有非常好的预防 regression 能力,并且对重构友好,但执行速度很慢。同时端到端测试也不好维护。
  • 无关紧要的测试满足重构友好和快速反馈,但对预防 regression 作用不大,例如用户名密码验证这类非常简单的逻辑。
  • 脆弱(过于敏感)的测试可以预防 regression 并快速执行,但对重构不友好。例如检查非常具体的实现逻辑没有发生变化。

图片

测试价值的取舍

考虑到实际中,是否重构友好往往是个非零即一的选项,所以一般无法在这一点上做妥协,我们只能在剩下的两点里做选择。例如 e2e 测试会更偏向防止代码功能失效,而典型的单元测试则更偏向于快速反馈。

测试的种类

既然聊到了不同测试在测试价值上的取舍,那自然就引出了不同测试的类型的问题。很多同学都会问,到底什么才算是单元测试?是测试一个具体的函数/方法,还是测试一个具体的类?这个粒度要有多细才能算单元测试呢?

个人认为有两种定义比较好理解和执行:

  • 单元指的应该是“业务功能单元”而不是类或者方法。为了实现一个业务功能,具体的实现,类和方法的拆分粒度都是在过程中可以重构的,测试本身不应该受到实现细节的影响,否则会变得支离破碎,难以理解和维护。
  • Google 软件工程的书中提到,他们把测试分成单个进程,单台机器,多台机器这三种类型。其中单元测试基本对应的是单进程中执行的小型测试。如果我们依赖了真实的数据库实例,那么显然就是跨进程了。

这两种定义下的单元测试都有比较明确的特点,如:

  • 每个测试验证一个业务单元的执行。
  • 能够快速运行。
  • 测试之间相互独立,可以同时运行多个测试而不会互相影响。

在这个基础上,集成测试会引入外部的依赖进行验证。而端到端测试可以算是集成测试的最极端的情况,完全使用真实组件来完成测试,例如直接在网页端进行点击操作,验证结果。Google 提到的中型和大型测试也基本可以对应上这两类测试。

图片

测试的不同种类

有了这三种测试的分类,就可以引出很多人都听说过的经典测试金字塔了:

图片

测试金字塔

由于单元测试本身专注于足够小的业务单元,执行又快,而且没有外部依赖更加稳定,因此很自然的一个想法就是在可能的情况下,都应该使用单元测试来覆盖功能验证。

例如一个系统有 3 个模块,每个模块又有 10 种不同的场景,如果我们只能通过端到端集成测试来做,需要的测试用例可能就需要 10 * 10 * 10 这个量级。而且不同的场景可能需要复杂的准备,例如有些需要保持数据库里没有数据,有些又需要预先有些数据,有些还需要特定组件抛出错误等。这些准备工作进一步加大了测试准备所需要的耗时,同时也会让这些测试之间很难共享依赖并发执行。集成测试还可能碰到环境不稳定的问题,也容易提升测试误报的可能。种种问题叠加起来,也不难理解即使集成测试会使用更多的“真实组件”,但在实践过程中仍然不是一个好的选择。

当然测试金字塔也有例外情况,例如业务逻辑越简单,则单元测试能发挥的作用就越小。或者外部依赖较少,使用真实依赖的成本较低时,也可以增加集成测试的数量。所以不同项目的测试金字塔构成比例也会因项目特性而有所不同。

什么是测试替代

为了在更多的场景下能够使用单元测试来覆盖场景,我们就需要引入一些测试替代(test doubles)来帮助我们模拟外部依赖。很多书里会区分 dummy,stub,fake,mock,spy 等概念,再加上很多框架自己就叫 mockxxx,经常让人看的有些云里雾里不明所以。

Unit Testing 这本书里给出了很系统化的分类方法,主要就分成两类:

  • 自内向外调用的模拟和检查,称为 mock。例如系统在用户注册成功后会发送一封邮件,那么我们需要检查是否调用了邮件服务来确认这个行为。
  • 自外向内获取的模拟,称为 stub。例如从数据库中查询某个用户的信息,我们只需要模拟返回信息即可,不需要去检查是否真的做了数据库调用。

图片

测试替代的不同类型

为什么 mock 需要做调用的检查,而 stub 却不需要呢?这又回到了我们之前提到过的对重构友好的重要原则:应该测试可观测的行为状态,而不是实现细节。对外发送一封邮件,这件事情最终是会被用户感受到的,所以属于“可观测的行为状态”,需要进行检查。而从数据库中获取用户信息,则不属于用户可以观测到的行为,而是实现细节。我们完全可以改成从不同的数据库中获取,或者从缓存中获取等等其他实现方式。

所以在这里,选择 mock 还是 stub 就有了个比较明确的标准:当被测组件向外调用的行为属于可观测行为状态时,才使用 mock,其它情况下使用 stub 即可。

不过问题到这里也还没结束,两本书中都提到了对于测试替代,业界还有两种不同的流派,分别是伦敦派与经典派。前面提到单元测试应该是相互隔离可以独立运行的,而这两个流派之间的主要分歧就在于对测试隔离范围的区别。

对于测试的外部依赖,我们可以分为两大类,分别是共享和私有。对于共享的依赖,如数据库,消息队列,第三方 API 需要被独立开来(测试替代),使得测试可以独立执行。这点对于两个流派来说都是一致的。

而私有依赖这部分,又可以区分为可变依赖和只读依赖。例如我当前正在测试用户购买行为,其中涉及到两个外部依赖,一个是商品,一个是库存。当进行购买时,商品只提供名称信息,是个只读对象;而库存则需要在购买成功后进行库存扣减操作,是个可变对象。这里就出现了两种流派的区别:伦敦派会替代掉私有依赖中的可变对象,而经典派则倾向于在私有依赖中都使用真实对象而不是测试替代。

图片

伦敦派与经典派的区别

在 Google 的那本书中,也提到他们对于测试替代的态度在两者之间游走。当前他们更倾向于完全不使用 mock,偶尔考虑使用 stub/fake,主要原因也是怕过多依赖 mock 会有 over specification 的问题(例如检查具体发出的邮件是什么),使得测试变得更加脆弱。可以说他们的实践是一种更加严格的经典派做法。

如何看待覆盖率

前面提到测试的价值标准,很多人可能会想到代码覆盖率这个经常被提起的概念。这里的覆盖率又分成好几种,包括:

  • 代码行覆盖
  • 分支/条件覆盖
  • 执行路径覆盖

不过目前绝大多数工具提供的还是代码行覆盖,辅助会带一些条件覆盖的指示(部分条件覆盖会显示黄色)。所以我们一般讨论的也是以行覆盖率指标为主。

覆盖率本身与测试价值是一个相关性指标,我们总体的评判标准是:低的代码覆盖率,一般来说是不好的,但高的代码覆盖率并不一定表示测试是优秀的。例如极端的例子如果测试中只有逻辑执行,没有 assertion 检查,那么 100% 覆盖率也是没有意义的。同理回到前面测试价值的评判标准,如果某些逻辑简单,或者非核心业务逻辑的代码,我们可能也没有必要去追求高覆盖率。

软件测试最佳实践

前面梳理了一下软件测试的基本概念,接下来我们来看下一些最佳实践,具体怎么来写高价值的自动化测试,以及测试本身对于优良架构设计的促进作用。

单元测试的构成

一般来说,单元测试的构成分三个部分:

  • Arrange:把被测对象和环境依赖设置到目标状态。
  • Act:调用被测对象的方法,执行操作。
  • Assert:验证结果的正确性,包括返回值,被测对象的状态,与外界的预期交互行为等所有属于可观测行为状态的内容。

Given-When-Then 模式与上述的 AAA 模式是等价的。

一般来说,一个单测里应该只有一组 AAA 的流程,保证验证场景的聚焦与清晰。在集成测试中,可能一些环境的 arrange 开销会比较大,所以有时候会考虑将之前的 arrange/act 给后续操作复用,例如先创建用户,验证用户创建成功,然后继续验证该用户的其它操作场景。

写测试中经常碰到的一个问题是 arrange 部分比较复杂,可以考虑引入一些设计模式,例如 object mother 和 test data builder 等,抽取一些共用方法来提效。这也属于对 test infra 的投入,会提升开发者写测试的效率。单元测试一般不太需要公共的 setup 和 teardown,共享的 arrange 可能会导致测试间的高度耦合,破坏测试之间的独立性。但在集成测试中很多 setup 是可能共用的,而且集成测试一般没法并行执行,可以在集成测试的基类中定义这些可复用的 fixture。

Act 部分一般一行代码调用就足够了,如果发现这部分的代码行数比较多,需要注意是否是 public API 的设计有问题。还是以测试购买行为为例,如果在操作时需要分别调用购买操作和库存扣减操作,那么显然就破坏了封装性。如果客户端只调用了购买,而忘了调用库存扣减,就会导致状态的不一致(invariant violation)。

Assertion 部分倒没有严格要求只用一行调用来验证,因为可观测的行为状态可能是多方面的,例如返回值,对象状态,外部调用行为等。要注意我们应该对可观测行为和状态进行检查,而不是实现细节。例如我们对返回 json 结构做原封不动的检查,可能就是不太好的实践,其中可能有些信息属于实现细节,这部分做重构优化时就很容易 break 测试。

图片

不要验证实现细节

另外要注意的是当 assertion 失败时的报错信息是否友好,很多时候只看到一个 expected False actual True 这样的信息显然是很难理解和定位问题的。有一些类似 AssertJ 的库可以帮助我们提升代码本身的可读性以及报错/callstack 的友好性。

除了结构外,也需要格外注意测试代码的可读性,包括测试函数的命名应该更贴近场景描述的自然语言,而不是跟具体的类名、方法名挂钩。同时也需要考虑减少重复代码,其它变量名,方法名的可读性等,这个跟功能代码是一致的。Google 的书中提到测试代码应该逻辑简单,不要有 if,for 循环这类,甚至可以允许一定的代码重复,一切以可理解性为前提。个人同意尽量减少测试代码的圈复杂度,但还是觉得也需要考虑一下可维护性,抽取一些共用方法,或者使用 property based 测试等。除了验证功能是否正常,可读性良好的测试也可以作为一种可以执行的文档帮助新人快速上手项目。

如何系统性地写测试

Effective Software Testing 中,给出了系统化的进行测试的步骤:

图片

系统化测试

主要关注右边方框里的内容。首先是基于软件的功能 spec 来开发测试:

图片

Spec Testing

个人理解具体展开包括:

  • 识别程序的每一个输入参数,它的类型是什么,可能的范围,特殊取值等。
  • 识别参数之间的相互关系,思考其可能的组合情况有哪些。
  • 识别程序的输出,其类型,可能的范围,特殊值等,反推需要覆盖的情况。
  • 识别代码中的各种边界条件,及相关的组合情况。
  • 基于业务定义 spec,针对上述的 case 进行发散性思考,查漏补缺。

这里尤其重要的是特殊值与边界条件的分析,也是最容易出现 bug 的地方。例如判断条件是x >= 10,那么就需要在x = 10x = 11这两个边界点上分别构造测试用例。

完成 spec testing 步骤后,我们可以进一步引入 structual testing,即前面提到的代码行覆盖的分析,辅助我们去完善测试用例。

图片

Structual Testing

通过代码行覆盖工具(例如 IntelliJ 中自带的,或者 JaCoCo 等),可以检查我们当前的测试用例覆盖度情况。对于还没有覆盖到的部分,分析是否需要添加用例来进行覆盖。我个人的标准是对于领域核心文件,一般要求达到 100% 的覆盖率。

在高代码覆盖率的基础上,我们还可以引入 mutaition testing。这是一个很有意思的技术,通过分析代码结构,去随机对功能代码做出修改,然后再执行相关的测试用例。如果修改后的代码(称为变种人)导致测试失败了,那么说明测试对于预防代码功能失效有着比较高的覆盖率。而如果修改后的代码如果还是成功跑通了,那就有可能说明测试用例不够完善。

图片

Mutation Testing

一般来说这三个步骤下来,我们的单元测试覆盖应该已经能达到一个及格线以上的水平了。后续像发现 bugfix 应该同步带上相关的单元测试等也是很多项目的常规做法,在此就不多做赘述。关于集成测试和端到端测试,我们放到后面再展开。

测试与代码设计的关系

前面我们已经提到很多次,如果发现测试不好写,可能跟代码设计不够好有关系。还有一些常见的问题包括,我是否应该测试 private API?什么情况下我应该用单元测试来覆盖,什么时候应该写集成测试?接下来我们就来展开看下这个话题。

现代软件工程的两个重要目标是控制软件整体的复杂度和快速获取反馈适应变化。前面我们看到测试可以在获取反馈方面给我们提供巨大的帮助,而且事实上测试对于控制复杂度方面也有很大的作用。

很多同学在写测试时会碰到一个问题,被测对象的依赖非常多,需要把它构建出来就很困难。这可能是我们在设计这个类时,没有很好地遵循单一职责原则。一个类如果要做太多事情,不够内聚,那么很可能就会变成一个边界模糊的“大杂烩”,依赖了一堆其它类来完成各不相同的工作,导致系统的耦合度也过高。对于我们的核心领域模型来说,应该尽可能做清晰的职责划分,减少依赖交互方。

也有同学会说,感觉 private API 比较复杂,需要做测试,但发现很多框架不支持 private API 的测试。这也是因为测试需要验证的应该是可观测行为和状态,而 private API 一般都属于实现细节,是很容易做重构改动的,那样的话测试的维护成本就会大大提高。如果发现 private API 很复杂,或者一个 public API 里涉及了很多步骤,其实也是缺乏单一职责设计的表象。可能更好的做法是把这些 private API 拆成单独的领域模型,去协同完成某个具体场景。

图片

理想的 public/private API 区分

另外像测试的验证比较复杂,不好写,可能也是类似的问题,没有做好职责分离和架构分层。举个极端的例子,我写了个void函数从头到尾把什么事情都做完了,那么自然要做 assertion 是件很困难的事情。

总结一下上面的问题,提出可测试性的几个直观要求:

  • 代码应该遵循单一职责原则,每个模块负责自己领域的事情,边界清晰。
  • 模块具有良好设计的 API,能够实现可控制,可观测。例如行为调用有清晰明确的返回信息,对象的状态可以通过接口进行查询等。
  • 需要有良好的架构来管理内部依赖(领域模型之间)和外部依赖(infra,数据库,API 等)。

这就很自然引出了很多讲架构的书中的典型分层方式,如六边形架构,clean architecture 等。其主要思想就是架构的内层是领域模型,包含所有的业务逻辑。外层是 service/controller 层,负责与外界的交互以及串联领域模型调用,尽可能不带业务逻辑。依赖关系永远是外层依赖内层,而不是反过来。典型的例子如我们不应该在领域模型里去建立数据库连接,依赖外部的 infra。直观的理解例如数组这样的领域对象,可以有访问,追加等操作,但如果它还有个方法是写入数据库,就会感觉有些“违和”了。

图片

六边形架构

书中还介绍了将这种风格推向极致的一种 functional architecture,把各类 side effects,exceptions,对内外部状态的依赖,都挪到业务逻辑的“外边缘”,让领域核心成为“纯函数”,也是控制代码复杂度的一种手段。不过这样做也会带来很多额外开销,例如性能和一些实现复杂度方面。

可测试性会“强迫”我们做出更好的架构设计,反过来,我们也可以通过架构设计来思考测试的分类。从上面六边形架构的示意图中我们也可以看出来,对于 controller 层测测试来说,领域模型之间的交互就属于实现细节,是不需要进行测试的。这其实就天然区分了单元测试和集成测试所负责的范围。

具体来说,我们以复杂度、领域相关度,和协作方(依赖)数量两个维度,来看领域模型与 controller 层的区别:

图片

架构分层与对应的测试

这张图可以说是非常精髓了。我们前面提到的测试难写,很多都落到了职责划分,架构分层不够清晰上,所以产生了很多过于复杂的代码(右上角)。为了提升可测试性,我们应该通过重构,把复杂代码拆分成领域模型和 controller 层两部分,前者负责复杂业务逻辑,后者负责大量的编排工作。因为领域核心的依赖交互方少,所以比较好写单元测试,而且这部分测试的价值也会比较高。而 controller 层因为没有复杂业务逻辑,但交互依赖众多,所以可以通过少量的集成测试来进行覆盖。有些同学会有疑问,我要测试一个用户购买的场景,是不是同样的功能测试要在单元测试和集成测试写两遍呢?这里就给出了比较清晰的指导原则。

图片

测试的“分形”特质

如果我们有比较良好的架构和职责划分,那么测试在不同层级上,是会具有这种“分形”特质的。书中还有一张图对 controller 层和领域模型层的特性给出了很形象的 code depth vs. width 说明:

图片

Controller 与领域模型的职责划分

当然在现实情况中要比这个理想情况复杂很多,我们通常很难把业务逻辑清晰地切割成“获取信息→执行业务逻辑→写回数据”这样的三步,很多时候会在业务过程中动态判断要读取哪些信息或者要写入哪些信息。这时候有几种选项:

  • 将决策过程切割成更细的步骤,在每个步骤保证信息读写处于 controller 层。
  • 将外部依赖(数据读写)注入到领域模型里。
  • 严格遵守把读写放在外围的方法,即使过程中不一定要用到的数据也都提前读取好。

这里具体的判断选择也有几个评估标准:

  • Controller 层的逻辑简单性。上述第一种选项牺牲了这一点。
  • 领域模型的可测试性。第二种选项依赖注入牺牲了这一点。
  • 性能。最后一种选项牺牲了这一点。

图片

现实世界的权衡

相比来说,性能和领域模型的可测性都比较重要。可以通过一些手段来管理 controller 的复杂度:

  • 即使做决策步骤拆分,也要避免把业务逻辑检查放到 controller 层来做。这可能会导致领域模型的不一致性。例如 controller 检查是否可以重命名,再由领域模型执行。如果 controller 层忘了检查,那么就会出现非法的情况被执行。
  • 可以使用 CanExecute/Execute 模式,在领域模型里提供检查方法,controller 可以调用,且自身执行逻辑前也会做检查。
  • Domain event 模式,将触发的操作放在 domain event 属性里,由 controller 或专门的 event dispatcher 去处理。相当于增加了一类协作交互对象,但没有提升 controller 的复杂度。

实在不可行的时候,再考虑把依赖注入到领域模型中。总体的原则还是尽量分离领域逻辑和编排这两种职责在整体架构的不同层去完成。

既然提到了依赖注入,也顺带一提为了保证可测试性,也会天然要求我们做依赖注入,而不是在类的内部去自己创建对象。另外像时间,随机数等依赖,也可以通过显式依赖传入,提升可控制性。

而为了在测试中能够方便地 mock/stub 各种依赖,提升测试的重构友好度,也非常建议我们应该依赖抽象接口,而不是具体的实现。这显然也是良好架构设计的一大指引原则,可以降低耦合。当然这里也不是硬性规则,需要与“过度设计”进行权衡。

图片

依赖接口而不是具体实现

TDD

前面一大块阐述了测试对于代码设计的促进作用,也正是因为测试不仅帮助我们保证质量,甚至还能促进我们做出好的设计,所以自然又诞生出了测试驱动开发(TDD)的思想。这不仅仅是把测试放在前面先写这么简单,而是会整体的改变我们的思维与行动方式,带来很多优点:

  • 根据需求先写测试,就是以需求为原点出发,先思考问题的抽象,而不是一上来考虑各种细节的技术手段选择。
  • 小批量,增量式开发,每次多通过一个测试。在不确定性的环境下,这种方式能提升我们学习的速度。其本身也是一种“关注点分离”的做法。
  • 快速的反馈,而不是等到全部写完再去检查是否有问题。
  • 可以提升代码的可测试性,因为从一开始就是先写测试。反之很容易以线性过程思考来写代码,没有可测试性的考量。
  • 对代码设计的即时反馈。可测试性差的代码,往往设计上就存在问题。测试驱动强迫我们持续去“使用”代码,能及早发现问题。
  • 事后写测试还有一个“陷阱”在于容易针对具体的实现去写,导致测试代码对于重构的友好程度降低。

当然也有一些例外情况,例如对于一次性使用的代码,不一定要写相关测试。如果对于需要写的功能已经非常熟悉,完全了解实现方法,也可以不用 TDD 的方式。

总体来说,只要有测试的需求,即使不严格遵循 TDD,我们也应该尽可能缩短实现功能与写测试中间的时间间隔。不要写了一整天的功能代码,最后再去补测试。

关于 TDD 的具体操作方式,可以参考 James Shore 的演示视频[5]

如何写大型测试

前面在架构分层对应的测试部分已经提到,对于 controller 层的测试,我们一般会通过集成测试的手段来覆盖。当然这里也有不同的选择,例如我们也可以 mock/stub 所有的外部依赖,用单元测试的方法来覆盖 controller 层。或者尽量使用真实的依赖来做集成测试。与单元测试相比,集成测试有以下特性:

  • 劣势:执行速度慢,更加难以维护。包括外部依赖的运维,更多的外部依赖也需要更多的 arrange 操作,让测试本身变得臃肿。
  • 优势:测试覆盖到了更多的代码,不光是内部的,也包括第三方库,外部依赖等,对于 regression 的预防能起到更好的作用。测试的维度一般也更贴近终端用户,重构友好的程度也会比较高。

前面也提到,controller 层一般是没有什么业务逻辑,但是依赖方众多,所以的确天然也更倾向于使用真实的依赖来做它的核心职责的验证。因此这里的取舍原则是,尽可能使用单元测试来覆盖各种复杂/边界场景,用集成测试来覆盖一条主要的 happy path(尽可能覆盖所有外部依赖),以及单元测试无法覆盖的边界场景。

集成测试中的外部依赖,也并不是完全不需要使用测试替代。我们可以从是否是应用自身管理的角度(典型特征就是看是否在同个代码 repo 里管理)来把它们分为两类:

  • managed:例如应用自身的数据库,其它应用不会直接访问这个数据库。对调用方来说,这部分属于实现细节。因此集成测试中,推荐直接使用这些真实依赖。
  • unmanaged:例如外部 API,邮件服务,消息队列等。应用与这些依赖的交互要保证兼容性。对调用方来说,这部分是可观测的行为,需要通过 mock 来模拟和验证。这也是 mock 唯一推荐的使用场景,其它情况下对于实现细节做交互验证,会让测试变得脆弱。

图片

不同的外部依赖类型

集成测试的极端情况就是全部使用真实依赖的端到端测试。不过整体来说端到端测试的质量保障范围与典型的集成测试接近,因此数量会更少。Google 一书中也提到,他们的端到端大型测试,一般都是在生产环境跑的(可以结合一些灰度发布手段),用于保障部署发布本身没有问题。此外一些特定需求的测试,如性能,可扩展性,稳定性,安全测试等,也经常会使用端到端的测试方法。

在自身管理的外部依赖方面,最常见的就是应用的数据库了。如何在集成测试中维护数据库呢?

  • 数据库 schema 的信息以及相关变更都应该通过 git 之类的管理起来。而不是大家共享某个具体的“标准测试数据库”。
  • Reference data(如一些映射关系)也应该是数据库 schema 的一部分。
  • 每个开发者都应该拥有自己独立的数据库 instance,集成测试本身就没法并发执行,共享数据库会碰到 schema,测试冲突等种种问题。
  • 使用 migration based 方法(如 Flyway,Liquibase),而不是 state based 方法来维护数据库变化。
  • 如何清理数据?最好的办法可能是在测试开始时执行清理。也可以选择每次测试前 restore 数据库,但会比较耗时。在测试结束后清理很多时候可能会没被执行到,例如测试挂掉,或者 debug 测试没有执行完等情况。
  • 尽量使用与真实生产环境相同的数据库,而不是为了测试跑的更快选择内存数据库来替代。

最后总结一些大型测试的最佳实践:

  • 明确 domain model 的边界。让代码更容易维护,也让测试更清晰,可以区分单元测试与集成测试。
  • 减少应用中的 layer 数量。层次越多,可能需要的集成测试越复杂,但没有带来实质性的收益。
  • 移除循环依赖。循环依赖会导致逻辑理解难度大大上升,对于测试也增加了接口抽象和 mock 的需求。
  • 即使在集成测试中,也尽量保证一个测试只验证一种情境,如创建用户和删除用户最好分成两个测试。只有在外部依赖的创建非常昂贵时,可以考虑在一个测试中做多个场景验证。
  • 大型测试的 setup 一般比较复杂,可以通过一些设计模式,第三方库来复用和简化代码,如 test data builder,java-faker[6] 等。
  • 同样,大型测试的 assertion 也会比较复杂,需要设计可复用,易读的 assertion API,也可以使用 AssertJ 这类三方库。
  • 每次测试前做状态清理。这里可能涉及到为测试专门开发的 reset API,要千万小心不要放到线上。如果可以,最好避免这种特殊 API 的做法。
  • 注意 ROI 测算,例如撰写,执行,维护大型测试的开销如何,是否能找到 bug,是否已经被 UT 覆盖等。任何代码都是“负债”,没有价值的测试应该及时被删除。
  • 可以通过对测试 infra 的投入,降低一些大型测试的成本。

图片

减少应用的 layer 数量

如何使用测试替代

测试替代永远是测试实践中的一个重要主题,前面我们已经提到了一些重要原则,例如应该测试可观测的行为状态,而不是实现细节。所以只有集成测试才需要使用 mock,并且应该仅被用在 unmanaged 依赖上,其它情况使用 mock 都会让测试变得脆弱。

延续这个思路,我们在验证与 unmanaged 依赖交互时,应该尽量在系统最外层的边界去做,这样能覆盖到更多的系统内部代码。

图片

Mock 位置的选择

在 mock 验证交互行为时,应该跟其它 assertion 一样,越精确越好。不光要看是否发生了调用,还要看调用的次数是否正确,以及不应该做的调用没有发生等。

放宽到测试替代整体来看,stub 使用的场景可能会更多一些,例如:

  • 速度较慢的外部依赖。例如某个类需要进行大量计算才能返回结果。
  • 基于外部 infra,如数据库,第三方 API 等。当然还是要注意,领域模型里应该尽量减少这些依赖。
  • 难以模拟的状态,如需要让某些组件抛出错误异常。

对于测试替代整体来说,我们应该仅仅替代自己“拥有”的类型,而不是直接去模拟第三方库的类型和 API。因为这些第三方库是有可能发生变化的,我们也可能会选择切换第三方的实现。所以更好的方法是先做抽象封装,让我们的应用去依赖抽象好的接口,而不是某个具体实现。

为了能在测试时灵活切换测试替代对象,需要使用依赖注入手段。这里有个选择,依赖注入是放在类构造方法里,还是放在其它方法的参数里?前者对于类本身的复杂度有所增加,但对于调用方来说会更加友好。多数情况下,我们还是选择让调用方更友好的方式。

测试质量

测试的代码也是我们项目代码的一部分,因此测试本身的质量也非常重要。这里简单列举一些基本的评判原则:

  • 测试应该执行速度较快。
  • 测试应该做到高内聚,互相独立,聚焦于某个场景。
  • 测试应该有一个存在的理由,为了预防 bug,而不仅仅是以提高代码覆盖率为目的。
  • 测试应该是稳定的,可重复执行,可复现的。谨慎引入外部依赖,各种超时设置等。不要与其他测试互相影响。
  • 测试应该有足够强的 assertion 覆盖。
  • 当被测对象行为发生变化时,测试应该失败。Mutation test 就是一个很好的检验方式。
  • 测试应该有且仅有一个明确的原因造成失败,便于定位问题和修复。
  • 测试应该易于撰写。需要对 test infra 有所重视与投入。
  • 测试应该易于阅读和理解。使用有意义的命名,通过 fluent API 来增强可读性等。
  • 测试应该易于修改和演进。

一些不好的测试代码 smell 表现与“反模式”:

  • 过多的重复代码,增加了修改演进难度。
  • 不明确的 assertion,报错信息不友好。
  • 没有处理好复杂的外部资源依赖,如数据库等。
  • 大量继承过于 general 的 fixtures,会很难维护。
  • 过于敏感的 assertion(针对实现细节),任何的代码改动都会让测试失败。
  • 测试私有方法。私有方法一般属于实现细节,而不是可观测的行为。测试私有方法会导致失去对重构的友好性,也是单元测试最重要的一个特性。如果发现 private 方法里有很复杂的业务逻辑,可以考虑做领域模型的拆分独立。
  • 基于私有属性做判断。同理,我们应该从业务的可观测行为角度进行判断,而不是把私有属性暴露出来。
  • 将领域知识引入到了测试中,极端的情况是在测试的 arrange 里直接把被测试代码的逻辑又复制了一遍过来。
  • 代码污染,在生产代码中添加了只为测试用例服务的逻辑。更好的做法是在测试中去做接口的不同实现。
  • Mock/stub 具体的类。往往违反了单一职责原则,建议将外部依赖拆出来接口化。

如何改进“遗留代码”

前面聊了很多测试的最佳实践,但绝大多数时候,我们接触的都是已有项目,不一定从一开始就注重单元测试这块的建设。对于没有测试覆盖的遗留代码,我们要如何入手呢?

这部分又是一个很大的话题,甚至有专门一本经典的书《修改代码的艺术[7]》来讨论。这里我们就简单介绍一些基本步骤和方法。

首先一个选择是,我们是否可以针对遗留代码来补充测试。这里测试的目标更多是希望保证原有系统在改造过程中不发生行为的变化,所以有个做法是先调用一下系统的功能,获取到输出后,就把这个输出作为检查点放到测试里。这种测试也被称为 characterization test。往往第一个测试是最难写的,因为要涉及到很多 setup,但有了第一个测试,在此基础上添加更多的用例就会相对容易不少。

但很多时候,由于一开始代码的设计并没有从可测试性角度出发思考,所以添加测试会非常的困难,而且写出来的测试在后续也有大概率会被修改掉。为了能让代码更容易进行测试,我们需要使用一些“安全重构”的手段来优化代码设计。很多 IDE 里都有这类功能的支持,如重命名,代码移动(拆分类),抽取方法等。注意在这个过程中只做搬运和重命名这类安全操作,不要同时修改逻辑,在没有测试覆盖情况下会很危险。

在遗留系统中添加新功能时,我们可以考虑创建一个新的类,同时对这个类加上单元测试。然后在旧有类中去调用这个新的类中的方法。以此来至少保证新功能是有测试覆盖的。

如果还需要对旧有代码做持续的 bugfix 等改动,那么也必然需要对遗留代码做重构和测试的补充。我们可以使用一些第三方工具来分析项目整体的依赖关系图,寻找明显问题,切入进行改造。这里的方法也是常见的重构手段,抽取类或者接口,把外部依赖做注入,实现多个实例化方法,在旧的类和方法里去调用逐渐抽取出来的新的类和方法。重构本身是没有完美状态的,我们只需要保证每次提交都比之前进步一些就可以。具体的衡量指标可以参考代码的测试覆盖率,SonarQube 等静态代码检查的结果等。

最后,如何培养整体团队的意识,让所有人都开始重视,学习,并掌握测试技术也是非常关键的。对于所有的新功能,bugfix,重构,都应该要求带上相应的测试代码覆盖。同时也需要在研发流程中提升测试的结合程度,完善测试相关的基础设施,让测试更好写,更容易执行并查看结果,逐渐让大家意识到测试带来的巨大价值。在赋能和培训方面,把这篇文章转发给团队,可能也是一个不错的开始 :)

参考资料

[1]

Accelerate: https://book.douban.com/subject/30192146/

[2]

Software Engineering at Google: https://book.douban.com/subject/34875994/

[3]

Effective Software Testing: https://book.douban.com/subject/35817720/

[4]

Unit Testing: https://book.douban.com/subject/34429421/

[5]

James Shore 的演示视频: https:///nlGSDUuK7C4

[6]

java-faker: https://github.com/DiUS/java-faker

[7]

修改代码的艺术: https://book.douban.com/subject/2248759/

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多