Unit Test Best Practices
前言
在传统的观念中,认为开发工程师的主要职责是编写代码,首先因为自测容易产生思维盲区,其次在紧迫的业务需求下,开发工程师可能会把测试工作放在次要位置。
实际上,开发工程师的职责应该跟随整个开发周期,包括开发、测试、持续集成和交付,而不仅仅是编写代码并简单地跑通流程进行提测。当然,专业领域需要专业人员,测试工程师拥有比开发工程师更专业的能力和手段来提高软件质量,但是开发工程师也具备独特的优势。
开发工程师通过测试能够快速反馈代码的正确性,这不仅能确保代码一直走在正确的道路上,也是遵循测试驱动开发(TDD)的一种实践。 通过编写测试,开发人员可以成为自己代码的第一个客户,并且能够及时发现代码中可能存在的接口设计不合理之处,从而进行重构,避免在最后交付测试之后再进行大规模的修改。
关于测试
本书主要讲如何在企业级应用当中单元测试的实践经验。首先企业级应用的特点:
- 复杂的业务逻辑
- 很长的生命周期
- 中等规模的数据
- 性能要求不高
如下图所示,一个没有测试的项目可能在初期增长迅速,但到了后期却很难再有进展,甚至无法增长。而在有测试的情况下,测试又分为好的测试和坏的测试两种情况。坏的测试最终会导致与没有测试一样的困境,这种现象称为“软件熵”,即系统的无序程度会随着代码修改的次数而增加。如果不对代码进行清理和重构,最终代码将变得不可靠。
因此,单元测试的目标是确保软件的可持续发展,即在长期的开发过程中,仍然可以持续演进。
如何衡量测试的质量
测试覆盖率是一个衡量测试质量的指标。指被执行的代码行数与代码总函数数之比。还有一个更准确的指标是分支覆盖率,即被测试的分支数与总分支数之比。
然而,并不是说测试覆盖率越高就越好。高覆盖率的代码并不一定就是无可挑剔、没有bug的代码,低覆盖率的代码也不一定质量很差。测试覆盖率是一个好的逆向指标,它可以帮助我们判断测试用例是否充分,但并不是一个好的正向指标。如果我们只是一味地追求高覆盖率,可能会产生反效果。因此,测试覆盖率只是测试质量的一个衡量指标而不是目标,需要结合其他测试指标和质量评估方法来综合评价测试的好坏。
测试也是有成本的,包括:
- 需要重构测试时的成本
- 每次修改代码时运行测试所需的时间成本
- 处理由测试引起的误报所需的成本
- 在试图了解代码的行为时需要阅读测试代码所需的时间成本。
因此,我们需要权衡测试的价值和成本,确保测试数量和质量达到一个合理的平衡点。
什么是一个成功的测试
一个成功的测试具有3个特征
- 它跟随整个开发周期
- 它只专注于最重要的那部分代码(一般是业务逻辑代码即领域模型),基础架构和外部库是不需要运行单测的。
- 用最小的维护成本来提供最大价值,所以需要程序员识别有价值的测试,并编写有价值的测试
什么是单元测试
单元测试一般指一个自动化的测试,核心条件包括
- 验证一小块代码
- 快速执行
- 以隔离的方式运行
关于隔离的不同理解形成了两种风格,伦敦派和经典派。
伦敦派认为单元测试通常是针对代码中的一个单元(通常是一个类)进行测试。在进行测试时,应该专注于被测试的代码,并使用测试替身来隔离与其交互的依赖项。这样做的好处包括提供更细粒度的测试、定位问题更容易以及测试速度更快。
然而,这种方法的问题在于,它并不合理地将单元定义为代码中的单个功能。相反,一个测试用例应当是对系统功能的内聚且有意义的描述。通过以类的角度进行拆分,测试用例可能会变得支离破碎,难以理解。此外,如果由于类之间的复杂关系而难以测试,则这是设计问题,使用测试替身只是隐藏问题而非解决问题。最后,对于单元测试来说,定位问题总是相对简单的,因此这种方法和关注单个功能的方法之间的差距很小。
经典派则认为一个单元应该是一个单一功能。相比之下,经典学派并不认为单元代码需要被隔离测试,而是认为单元测试本身应该在相互隔离的情况下执行,以确保各个测试在运行中互不影响。 在进行单元测试时,只有在共享依赖的情况下才需要使用mock。本书的观点偏向于经典学派。
关于依赖分类,可以分为以下几种
- 共享的依赖是指会对测试之间的结果产生影响的依赖,比如静态变量和数据库。在这里,共享指的是单元测试之间的共享,而不是单元内部类之间的共享。
- 私有的依赖是不共享的依赖。
- 进程外的依赖是指应用程序之外的依赖,比如数据库、文件系统和第三方程序。数据库既可以是共享依赖,也可以是进程外依赖。例如,如果每次使用docker重新启动数据库,那么它就不是共享的依赖。
下图展示两种风格是怎么处理依赖的
在TDD和过度规范的问题上,伦敦派和经典派之间也有所不同。伦敦派采用自上而下的TDD方式,通过mock掉交互方,可以先编写高层次的测试来为整个功能设定目标,然后逐步细化具体实现。相比之下,经典派则更倾向于使用自下而上的TDD流程,先建立核心的领域模型,再逐步添加周边功能。
两个流派最重要的区别在于过度规范的问题,即测试用例与系统实现细节的耦合。伦敦派更容易产生这种耦合,这也是本书对伦敦派和滥用mock最反对的地方。
如何组织一个单元测试
一般提倡AAA测试范式,所谓AAA测试范式指的是
- Arrange: 组织初始化一些参数和依赖。
- Act: 执行被测试函数。
- Assert:对输出结构断言,包括返回值、SUT的状态、交互方的状态以及预期交互行为
还有一个对应的Given-When-Then范式。在编写单元测试时,最好从Arrange开始,逐步完成测试。避免一个测试中多个Arrange、Act、Assert。
单元测试最好遵循单一职责原则,确保测试简单、快速、易于理解,以下是一些实践建议:
- 如果一个测试包含多个行为,请重构成多个单独的测试。
- 避免在测试中使用if语句,保证测试步骤简单、串行。
- Arrange部分通常是最大的,但过大也会影响可读性,可以将比较复杂的对象初始化和数据构造抽取为函数。
- Act部分通常只有一行代码,即被测试函数的调用。
- Assert部分应该针对被测试函数的每个行为进行断言,因为单元测试是测试行为而不是代码,而一个函数可能有多个行为,所以Assert可能会有多个。
- 如果存在第三方资源(如数据库)的依赖,可以在集成测试中使用Teardown阶段释放资源,单元测试一般不需要考虑此类情况,因为单元测试不会有太多第三方依赖。
单元测试的四大支柱特性
这是本书中最核心的内容,一个好的单元测试应该具备以下四个特性,Protection against regressions(防止回归),Resistance to refactoring(抵御重构),Fast feedback(快速反馈),Maintainability(可维护性)。
- 快速反馈意味着只有测试足够快,才能够鼓励开发人员编写更多的测试,并且更经常地运行它们。
- 可维护性则包括测试代码的可理解性和测试代码运行的易用性 。
- 防止回归指的是当代码中出现bug的时候,能够被测试所发现。通常情况下,当修改代码后导致原有功能失效时,这些问题只有通过测试才能被发现。因此,测试应该覆盖尽可能多的代码,以确保代码的稳定性和质量。代码不是资产,而是负债,因此代码越多,越容易出现问题。
- 抵御重构,当你这是重构了一些代码(主要指非功能性修改,比如rename,调整代码结构等),测试却失败了。这种情形叫false positive也就是误报,即测试失败,但实际上被测试代码的功能却一切正常。 而false positive的干扰会带来两个问题:
- 如果测试失败的原因不充分,就会削弱你对代码中问题做出反应的能力和意愿。随着时间的推移,你可能会忽略本来应该出现的错误。
- 如果false positive太多,你会对测试失去信任,这种信任会导致更少的重构。
那么是什么导致了false positive呢?
- 测试代码和被测试代码耦合越多,就越容易触发false positive。
- 唯一减少false positive的方法就是将测试和实现细节解耦。
- 记住测试的是行为而不是步骤。测试应该从终端用户的角度来验证SUT,并且只检查对终端用户有意义的结果。其他一切都必须被忽略。
因此,测试要以最终结果为目标,而不是以实现细节为目标。
上图中左边只测试最终结果是一个好的测试,因为它关注的是被测代码的行为。这样的测试也会产生false postive,不过很少而且也很容易解决。
测试结果的四个标准
如上图所示,关于测试的结果可以分成四种情况
- True Negatives:测试通过,且功能符合预期。
- False Negative: 测试通过,但功能不符合预期。说明测试没有测出bug,protection against regressions 特性可以避免这种情况。
- Flase positive:测试失败,但功能符合预期。 resistance to refactoring 特性可以避免这种情况。
- True positive: 测试失败,功能不符合预期。
我们需要关注的是 false negative(被忽略的bug)和 false positive(误报bug)这两类情况。
在项目初期,false positive 的影响并不那么严重。然而,随着项目的发展,测试数量和重构频率的增加,控制 false positive 变得更加重要。如果误报频繁出现,开发者会很快失去对测试的信任,从而导致测试的存在意义丧失。
是否存在完美的测试
如果用一个分值来衡量测试的质量,可以针对这四个标准,每个标准的分数范围从0到1。总分则是四个分数的乘积,分数越高表示测试的价值越高。如果有一个维度得分为零,整体测试的价值就会迅速降到零。因此,一个完美测试得分为1。 分析表明其中protection against regressions, resistance to refactoring, fast feedback 这三个属性无法三者同时满足,最多只能满足两个。所以需要在寻找一些平衡点。通过下面这些较极端的例子来说明为什么不能同时满足。
- E2E测试
-
-
E2E 测试能覆盖很多代码,可以很好实现protection against regressions。E2E测试不关注实现也能避免错误警报,只关注feature的功能是否正确。但是有个致命的问题,执行速度很慢。
-
- 无关紧要的测试
-
-
无关紧要的测试只覆盖一些很简单代码,所以执行返回速度会很快。因为简单它们产生误报的可能性也很低,因此有很好的resistance to refactoring特性。 但是因为它覆盖的代码量少,只要简单修改就会有很大影响。
-
- 脆弱的测试
- 可以实现protection against regressions并快速执行,但对重构不友好。例如一个组装SQL语句的函数。
因此,结论是不存在完美的测试。在现实中resistance to refactoring往往是一个非0即1的选项一般需要最大化,很难在这上面做妥协。通常需要在protection against regressions 和 fast feedback之间做权衡。当然Maintainability也是一个需要最大化的选项,因为它相对独立,不需要在其他特性之间做出妥协。
类似于分布式系统中的CAP定律,分区容忍性是必选项,只能在C和A上面权衡。
可以通过将测试细分为不同的领域来权衡不同类型的测试,从而达到更好的效果。
就测试数量而言,E2E测试应该是少量的,而UnitTest则应该是最多的。E2E测试在fast feedback和maintainability方面的表现通常都比较差,因为其规模往往是最大的,需要额外的维护工作来处理所涉及的外部依赖。所以会更偏向protection against regressions,而Unit Test往往会更注重反馈速度。
另外需要对上面对测试金字塔进行说明,不同代码的测试金字塔形状也会有所不同。例如,如果代码只包含简单的CRUD操作,那么就不需要E2E测试,而集成测试和UT的规模将相当。对于没有算法或业务复杂性的环境,单元测试可能不太有用,因为它们很快就会沦为琐碎的测试。
另一个例外是一个API,它需要访问进程外的依赖项,比如一个数据库。对于这种情况,拥有更多的E2E测试可能是一个可行的选择。由于没有用户界面,端到端测试将运行得相当快。同时,维护成本也不会太高,因为你只需要与单一的外部依赖,即数据库一起工作。
Mock
测试替身一般分为mock和stub
Mock 有助于模拟和检查由内向外发生的交互,这些交互是SUT对其依赖关系的调用,以改变其状态。Mock可以进一步细分为mock和spy两种类型,区别在于spy是手动写的,mock则是由mock框架生成。
与Mock不同的是,Stub是有助于由外向内的交互,这些交互是SUT对其依赖关系的调用,以获得输入数据。在Stub中,还可以进一步细分为stub、dummy和fake三种类型。其中,dummy是一种简单的硬编码返回值,例如null或预先设定的某个值;stub也会返回特定的值,但会根据不同的场景返回不同的值;而fake和stub类似,但其模拟的是一个尚未存在的依赖关系。
如上图中,发送邮件是一个外部的交互,替换掉SMTP服务是mock。而从DB接收数据是一个内部交互,替换掉DB则是一个stub。
代码设计与测试
代码可以按照两个维度进行分类:公共API和私有API,以及可观测行为和实现细节。 一个设计良好的公共API应该包含可观测行为,而所有实现细节都应该被隐藏在私有API中。
通过隐藏实现细节,良好的API设计可以防止客户端破坏类的内部结构。同时,将数据和操作捆绑在一起可以确保操作不会违反类的不变性。只有直接帮助客户实现目标的代码应该被公开,而其他代码则应该被隐藏在私有API后面。这样的API设计有助于提高测试的质量。
也就是说码应该遵循单一职责原则,每个模块负责自己领域的事情,边界清晰。
书中介绍了六边形架构和函数式编程,六边形架构主要关注三个方面
- 领域层和应用服务层的关注点分离。领域层为业务逻辑负责,应用服务层编排流程。
- 只有应用服务层到领域层的单向依赖
- 外部应用通过应用服务层来连接系统,不能直接访问领域层。
跨系统的通信是可观测行为,而系统内部的通信都是实现细节。进程外的依赖,如果使用方只有SUT自己,那么也不应该归类到可观测行为,而应该属于实现细节,比如数据库。Mock应该只关注系统的可观测行为,而不应该用于验证实现细节,否则会使得测试非常脆弱。
函数式编程也是常见的一种架构模式,函数式编程的代码可以分为两部分:内部是无状态的业务逻辑,包含了主要的复杂度,而外部则是状态处理的壳。这个壳应该越简单越好,它需要收集所有的输入信息,函数式的核心部分产生决策,而外部基于这些决策产生各种副作用。我们的目标是使用基于输出的测试尽可能地覆盖核心部分,而将外部流程留给少量的集成测试。
与六边形架构相比,函数式编程将所有状态都放到了领域逻辑之外,而六边形架构则允许存在内部状态。
Unit Test的三种风格
基于输出的测试假定SUT没有隐藏状态,因此运行测试会得到唯一的返回值。这种测试具有较高的质量,因为所有的交互只关注API,没有涉及实现细节。由于测试仅与SUT耦合,因此易于重构和维护。
基于状态的测试是指验证一个操作后系统本身或其依赖的外部进程(例如文件系统或数据库)的状态,例如验证类的成员变量或数据库中的值是否为预期值。这种测试与SUT会有一定的耦合。由于此类测试与类的状态一起工作,因此容易出现false positive,并需要泄漏一些实现细节。通常需要验证多个状态,因此可维护性可能较差。
基于交互的测试利用mock来验证被测系统与其依赖项的通信,例如mock SMTP系统以发送电子邮件。这种风格被广泛应用于伦敦派的编程风格。然而,这种测试风格需要大量的mock,protection against regressions 和可维护性通常较差。使用大量mock可能导致浅层测试,仅能验证少量代码。此外,绝大多数检查测试替身和交互的测试最终都很脆弱,容易出现false positive。由于需要设置测试替身和交互断言,因此可维护性也非常差。
向有价值的单元测试重构
代码的复杂度可以指其中决策点的数量,包括直接在代码中声明的和通过类库间接声明的。通常来说,复杂的代码也是对于问题领域影响最大的代码,也是从单元测试中受益最明显的部分。下图展示了四种代码类型:
- 领域模型和算法:复杂的代码往往是领域模型的一部分,但不是100%的情况。你可能有一个复杂的算法,但与问题领域没有直接关系。它们拥有最高的复杂度,但是不应该有太多交互方
- 琐碎的代码:C#中这类代码的例子是无参数的构造函数和单行属性,它们的交互者很少(如果有的话),表现出很少的复杂性或领域意义。
- 控制器:这种代码本身并不做复杂或关键业务的工作,而是协调其他组件的工作,如领域类和外部应用程序。
- 过度复杂的代码:这种代码在两个指标上都得分很高:它有很多交互方,而且也很复杂或重要。这里的一个例子是胖控制器(控制器不把复杂的工作委托给任何地方,自己做所有的事情)。
在四种代码类型中,左上角的代码是对于单元测试价值最大的,而左下角琐碎的代码通常对于单元测试来说没有意义,控制器部分也只需要进行简单测试即可。相反,过于复杂的代码测试是最困难的,如果不测试则存在非常大的风险。因此,我们的目标是尽可能避免测试这种代码,需要对代码进行拆解和优化,以使测试更有价值。
使用Humble Object是优化复杂代码的一个好方法。可以将业务逻辑从过于复杂的代码中提取出来,进入领域逻辑象限,剩余部分进入控制器象限。这样可以使得领域逻辑更加清晰,也更容易进行基于输出的单元测试。
代码的复杂度可以分为深度和宽度两个方面,代码要么很深,要么很宽,无法同时兼顾。六边形架构和函数式编程都体现了这种思想,只是函数式编程做到了极致。
领域逻辑是单元测试最能发挥作用的地方,而控制器部分只需要通过集成测试来简要覆盖,琐碎代码则完全不必测试。因此,我们应当关注合理的测试覆盖率,而不是追求100%的覆盖率,以发挥测试最大的价值。
总的来说Humble Object是将业务逻辑从过于复杂的代码中提取出来,进入领域逻辑象限,剩余部分进入控制器象限。这个过程需要平衡三个方面
- 领域模型的可测试性,交互方越少越容易测试
- 控制器的简单性,决策点越少越简单
- 性能,主要体现在进程外交互的数量
然而,因为许多交互流程取决于业务逻辑的判断,各种方案都可能导致某个方向受损。
- 如果把所有交互方都放在控制器层,则需要在领域逻辑外准备好所有可能用到的数据,导致性能降低。
- 如果把交互逻辑放到领域模型内,则会影响可测试性。
- 拆分领域模型,把决策流程分为多个小步骤,又会增加控制器的复杂度。这种方案是相对合理的取舍。也可以使用一些模式来减少对于控制器复杂度的影响
Mock的最佳实践
- 理论上只有controller需要和非受控依赖打交道,所以单测是不需要mock的,只有集成测试要使用mock
- 在测试中不要依赖生产代码的逻辑,可以重新定义常量和字面值,否则无法进行有效的检查。
- 对于log这样的非受控依赖,可以不需要太关注具体的结构,只需要验证其存在以及核心信息即可
- 在mock中验证交互的次数,既要关注预期发生的交互,也要关注预期不发生的交互。
- 不要mock不属于自己的类型,对于非受控的依赖应当写一个适配器(adaptor),并mock这个适配器。适配器就是反腐层,可以使用本应用的领域语言,并且只需要包含真正使用到的功能。
Unit Test的反模式
- 暴露私有方法来给单元测试提供方便会导致测试与实现的耦合,进而破坏测试对重构的抵抗力。为了避免直接测试私有方法,可以间接测试它们作为总体可观察行为的一部分。
- 在某些情况下,如果私有方法过于复杂无法作为使用该方法的公共API的一部分进行测试,这表明需要一个抽象概念。应将该抽象提取到一个单独的类中,而不是将私有方法变成公共的。
- 在编写测试时,不要暗示任何特定的实现,应从黑盒的角度验证生产代码,避免将实现细节泄露给测试。
- 代码污染是一种反模式,应避免将仅用于测试的生产代码添加到生产代码中,这会混淆测试和生产代码,并增加后者的维护成本。
- 如果必须对一个具体的类的部分功能进行模拟,则可能违反了单一责任原则。应将该类分为两部分:一部分处理领域逻辑,另一部分与进程外的依赖关系进行通信。
- 另外,将当前时间作为环境上下文来表示会污染生产代码,使测试更加困难。应该将时间作为一个显式的依赖关系注入,要么作为一个服务,要么作为一个参数。在注入时尽可能选择使用参数。