广阔天地大有作为

你想拥有什么,就去追求什么

18 Mar 2023

Unit Test Best Practices

前言

在传统的观念中,认为开发工程师的主要职责是编写代码,首先因为自测容易产生思维盲区,其次在紧迫的业务需求下,开发工程师可能会把测试工作放在次要位置。

实际上,开发工程师的职责应该跟随整个开发周期,包括开发、测试、持续集成和交付,而不仅仅是编写代码并简单地跑通流程进行提测。当然,专业领域需要专业人员,测试工程师拥有比开发工程师更专业的能力和手段来提高软件质量,但是开发工程师也具备独特的优势。

开发工程师通过测试能够快速反馈代码的正确性,这不仅能确保代码一直走在正确的道路上,也是遵循测试驱动开发(TDD)的一种实践。 通过编写测试,开发人员可以成为自己代码的第一个客户,并且能够及时发现代码中可能存在的接口设计不合理之处,从而进行重构,避免在最后交付测试之后再进行大规模的修改。

关于测试

本书主要讲如何在企业级应用当中单元测试的实践经验。首先企业级应用的特点:

如下图所示,一个没有测试的项目可能在初期增长迅速,但到了后期却很难再有进展,甚至无法增长。而在有测试的情况下,测试又分为好的测试和坏的测试两种情况。坏的测试最终会导致与没有测试一样的困境,这种现象称为“软件熵”,即系统的无序程度会随着代码修改的次数而增加。如果不对代码进行清理和重构,最终代码将变得不可靠。

image.png

因此,单元测试的目标是确保软件的可持续发展,即在长期的开发过程中,仍然可以持续演进。

如何衡量测试的质量

测试覆盖率是一个衡量测试质量的指标。指被执行的代码行数与代码总函数数之比。还有一个更准确的指标是分支覆盖率,即被测试的分支数与总分支数之比。

然而,并不是说测试覆盖率越高就越好。高覆盖率的代码并不一定就是无可挑剔、没有bug的代码,低覆盖率的代码也不一定质量很差。测试覆盖率是一个好的逆向指标,它可以帮助我们判断测试用例是否充分,但并不是一个好的正向指标。如果我们只是一味地追求高覆盖率,可能会产生反效果。因此,测试覆盖率只是测试质量的一个衡量指标而不是目标,需要结合其他测试指标和质量评估方法来综合评价测试的好坏。

测试也是有成本的,包括:

因此,我们需要权衡测试的价值和成本,确保测试数量和质量达到一个合理的平衡点。

什么是一个成功的测试

一个成功的测试具有3个特征

什么是单元测试

单元测试一般指一个自动化的测试,核心条件包括

关于隔离的不同理解形成了两种风格,伦敦派和经典派。

伦敦派认为单元测试通常是针对代码中的一个单元(通常是一个类)进行测试。在进行测试时,应该专注于被测试的代码,并使用测试替身来隔离与其交互的依赖项。这样做的好处包括提供更细粒度的测试、定位问题更容易以及测试速度更快。

Pasted image 20230212155156

然而,这种方法的问题在于,它并不合理地将单元定义为代码中的单个功能。相反,一个测试用例应当是对系统功能的内聚且有意义的描述。通过以类的角度进行拆分,测试用例可能会变得支离破碎,难以理解。此外,如果由于类之间的复杂关系而难以测试,则这是设计问题,使用测试替身只是隐藏问题而非解决问题。最后,对于单元测试来说,定位问题总是相对简单的,因此这种方法和关注单个功能的方法之间的差距很小。

经典派则认为一个单元应该是一个单一功能。相比之下,经典学派并不认为单元代码需要被隔离测试,而是认为单元测试本身应该在相互隔离的情况下执行,以确保各个测试在运行中互不影响。 在进行单元测试时,只有在共享依赖的情况下才需要使用mock。本书的观点偏向于经典学派。

关于依赖分类,可以分为以下几种

Pasted image 20230212160251

  1. 共享的依赖是指会对测试之间的结果产生影响的依赖,比如静态变量和数据库。在这里,共享指的是单元测试之间的共享,而不是单元内部类之间的共享。
  2. 私有的依赖是不共享的依赖。
  3. 进程外的依赖是指应用程序之外的依赖,比如数据库、文件系统和第三方程序。数据库既可以是共享依赖,也可以是进程外依赖。例如,如果每次使用docker重新启动数据库,那么它就不是共享的依赖。

下图展示两种风格是怎么处理依赖的

Pasted image 20230212210742

在TDD和过度规范的问题上,伦敦派和经典派之间也有所不同。伦敦派采用自上而下的TDD方式,通过mock掉交互方,可以先编写高层次的测试来为整个功能设定目标,然后逐步细化具体实现。相比之下,经典派则更倾向于使用自下而上的TDD流程,先建立核心的领域模型,再逐步添加周边功能。

两个流派最重要的区别在于过度规范的问题,即测试用例与系统实现细节的耦合。伦敦派更容易产生这种耦合,这也是本书对伦敦派和滥用mock最反对的地方。

如何组织一个单元测试

一般提倡AAA测试范式,所谓AAA测试范式指的是

还有一个对应的Given-When-Then范式。在编写单元测试时,最好从Arrange开始,逐步完成测试。避免一个测试中多个Arrange、Act、Assert。

Pasted image 20230212213447

单元测试最好遵循单一职责原则,确保测试简单、快速、易于理解,以下是一些实践建议:

单元测试的四大支柱特性

这是本书中最核心的内容,一个好的单元测试应该具备以下四个特性,Protection against regressions(防止回归),Resistance to refactoring(抵御重构),Fast feedback(快速反馈),Maintainability(可维护性)。

那么是什么导致了false positive呢?

因此,测试要以最终结果为目标,而不是以实现细节为目标。

Pasted image 20230224083249

上图中左边只测试最终结果是一个好的测试,因为它关注的是被测代码的行为。这样的测试也会产生false postive,不过很少而且也很容易解决。

测试结果的四个标准

Pasted image 20230226151235

如上图所示,关于测试的结果可以分成四种情况

我们需要关注的是 false negative(被忽略的bug)和 false positive(误报bug)这两类情况。

Pasted image 20230226153259

在项目初期,false positive 的影响并不那么严重。然而,随着项目的发展,测试数量和重构频率的增加,控制 false positive 变得更加重要。如果误报频繁出现,开发者会很快失去对测试的信任,从而导致测试的存在意义丧失。

是否存在完美的测试

如果用一个分值来衡量测试的质量,可以针对这四个标准,每个标准的分数范围从0到1。总分则是四个分数的乘积,分数越高表示测试的价值越高。如果有一个维度得分为零,整体测试的价值就会迅速降到零。因此,一个完美测试得分为1。 分析表明其中protection against regressions, resistance to refactoring, fast feedback 这三个属性无法三者同时满足,最多只能满足两个。所以需要在寻找一些平衡点。通过下面这些较极端的例子来说明为什么不能同时满足。

因此,结论是不存在完美的测试。在现实中resistance to refactoring往往是一个非0即1的选项一般需要最大化,很难在这上面做妥协。通常需要在protection against regressions 和 fast feedback之间做权衡。当然Maintainability也是一个需要最大化的选项,因为它相对独立,不需要在其他特性之间做出妥协。

Pasted image 20230227081201

类似于分布式系统中的CAP定律,分区容忍性是必选项,只能在C和A上面权衡。

可以通过将测试细分为不同的领域来权衡不同类型的测试,从而达到更好的效果。

Pasted image 20230227081621

就测试数量而言,E2E测试应该是少量的,而UnitTest则应该是最多的。E2E测试在fast feedback和maintainability方面的表现通常都比较差,因为其规模往往是最大的,需要额外的维护工作来处理所涉及的外部依赖。所以会更偏向protection against regressions,而Unit Test往往会更注重反馈速度。

Pasted image 20230227081826

另外需要对上面对测试金字塔进行说明,不同代码的测试金字塔形状也会有所不同。例如,如果代码只包含简单的CRUD操作,那么就不需要E2E测试,而集成测试和UT的规模将相当。对于没有算法或业务复杂性的环境,单元测试可能不太有用,因为它们很快就会沦为琐碎的测试。

另一个例外是一个API,它需要访问进程外的依赖项,比如一个数据库。对于这种情况,拥有更多的E2E测试可能是一个可行的选择。由于没有用户界面,端到端测试将运行得相当快。同时,维护成本也不会太高,因为你只需要与单一的外部依赖,即数据库一起工作。

Mock

测试替身一般分为mock和stub

Pasted image 20230228080335

Mock 有助于模拟和检查由内向外发生的交互,这些交互是SUT对其依赖关系的调用,以改变其状态。Mock可以进一步细分为mock和spy两种类型,区别在于spy是手动写的,mock则是由mock框架生成。

与Mock不同的是,Stub是有助于由外向内的交互,这些交互是SUT对其依赖关系的调用,以获得输入数据。在Stub中,还可以进一步细分为stub、dummy和fake三种类型。其中,dummy是一种简单的硬编码返回值,例如null或预先设定的某个值;stub也会返回特定的值,但会根据不同的场景返回不同的值;而fake和stub类似,但其模拟的是一个尚未存在的依赖关系。

Pasted image 20230228080642

如上图中,发送邮件是一个外部的交互,替换掉SMTP服务是mock。而从DB接收数据是一个内部交互,替换掉DB则是一个stub。

代码设计与测试

Pasted image 20230228083751

代码可以按照两个维度进行分类:公共API和私有API,以及可观测行为和实现细节。 一个设计良好的公共API应该包含可观测行为,而所有实现细节都应该被隐藏在私有API中。

通过隐藏实现细节,良好的API设计可以防止客户端破坏类的内部结构。同时,将数据和操作捆绑在一起可以确保操作不会违反类的不变性。只有直接帮助客户实现目标的代码应该被公开,而其他代码则应该被隐藏在私有API后面。这样的API设计有助于提高测试的质量。

也就是说码应该遵循单一职责原则,每个模块负责自己领域的事情,边界清晰。

书中介绍了六边形架构和函数式编程,六边形架构主要关注三个方面

跨系统的通信是可观测行为,而系统内部的通信都是实现细节。进程外的依赖,如果使用方只有SUT自己,那么也不应该归类到可观测行为,而应该属于实现细节,比如数据库。Mock应该只关注系统的可观测行为,而不应该用于验证实现细节,否则会使得测试非常脆弱。

Pasted image 20230303080014

函数式编程也是常见的一种架构模式,函数式编程的代码可以分为两部分:内部是无状态的业务逻辑,包含了主要的复杂度,而外部则是状态处理的壳。这个壳应该越简单越好,它需要收集所有的输入信息,函数式的核心部分产生决策,而外部基于这些决策产生各种副作用。我们的目标是使用基于输出的测试尽可能地覆盖核心部分,而将外部流程留给少量的集成测试。

与六边形架构相比,函数式编程将所有状态都放到了领域逻辑之外,而六边形架构则允许存在内部状态。

Unit Test的三种风格

Pasted image 20230303082911

基于输出的测试假定SUT没有隐藏状态,因此运行测试会得到唯一的返回值。这种测试具有较高的质量,因为所有的交互只关注API,没有涉及实现细节。由于测试仅与SUT耦合,因此易于重构和维护。

Pasted image 20230303083106

基于状态的测试是指验证一个操作后系统本身或其依赖的外部进程(例如文件系统或数据库)的状态,例如验证类的成员变量或数据库中的值是否为预期值。这种测试与SUT会有一定的耦合。由于此类测试与类的状态一起工作,因此容易出现false positive,并需要泄漏一些实现细节。通常需要验证多个状态,因此可维护性可能较差。

Pasted image 20230303083442

基于交互的测试利用mock来验证被测系统与其依赖项的通信,例如mock SMTP系统以发送电子邮件。这种风格被广泛应用于伦敦派的编程风格。然而,这种测试风格需要大量的mock,protection against regressions 和可维护性通常较差。使用大量mock可能导致浅层测试,仅能验证少量代码。此外,绝大多数检查测试替身和交互的测试最终都很脆弱,容易出现false positive。由于需要设置测试替身和交互断言,因此可维护性也非常差。

向有价值的单元测试重构

代码的复杂度可以指其中决策点的数量,包括直接在代码中声明的和通过类库间接声明的。通常来说,复杂的代码也是对于问题领域影响最大的代码,也是从单元测试中受益最明显的部分。下图展示了四种代码类型:

Pasted image 20230307081518

在四种代码类型中,左上角的代码是对于单元测试价值最大的,而左下角琐碎的代码通常对于单元测试来说没有意义,控制器部分也只需要进行简单测试即可。相反,过于复杂的代码测试是最困难的,如果不测试则存在非常大的风险。因此,我们的目标是尽可能避免测试这种代码,需要对代码进行拆解和优化,以使测试更有价值。

Pasted image 20230307082042

使用Humble Object是优化复杂代码的一个好方法。可以将业务逻辑从过于复杂的代码中提取出来,进入领域逻辑象限,剩余部分进入控制器象限。这样可以使得领域逻辑更加清晰,也更容易进行基于输出的单元测试。

Pasted image 20230307083149

代码的复杂度可以分为深度和宽度两个方面,代码要么很深,要么很宽,无法同时兼顾。六边形架构和函数式编程都体现了这种思想,只是函数式编程做到了极致。

领域逻辑是单元测试最能发挥作用的地方,而控制器部分只需要通过集成测试来简要覆盖,琐碎代码则完全不必测试。因此,我们应当关注合理的测试覆盖率,而不是追求100%的覆盖率,以发挥测试最大的价值。

总的来说Humble Object是将业务逻辑从过于复杂的代码中提取出来,进入领域逻辑象限,剩余部分进入控制器象限。这个过程需要平衡三个方面

然而,因为许多交互流程取决于业务逻辑的判断,各种方案都可能导致某个方向受损。

Mock的最佳实践

Unit Test的反模式