《Google 软件工程》第 13 章——Test Doubles

《Google 软件工程》第 13 章——Test Doubles

前言:对于软体工程的兴趣随着职涯的年龄与日俱增,恰好前阵子发现Google 出了关于软体工程的经验谈。 《Software Engineering at Google》这一系列的文章是想分享我阅读《Software Engineering at Google》这本书的笔记。透过汲取更多前人的经验,来让自己对于软体工程方面的scalability 能够更有感触。希望可以给自己的学习历程留下一些什么,也希望对想了解这方面知识的人有一些帮助。

在软体开发中,单元测试是相当重要的工具。尽管对于简单的程式码撰写单元测试可以说是非常容易,但是随着程式码越来越复杂,要将单元测试写得好却也会逐变得困难。举例来说,当你尝试想要发送一个request 到一个外部的server 然后将其回应储存在database 中,写一个测试还算可行,但当你写了数百个这样的测试时,可能会花费你数个小时才能跑完,更糟的是,可能还会由于网路不稳定等地关系造成测试结果不稳定。

在这种情形下,就是test double 派上用场的时候了。 测试双 指的是在测试中能代替函式或物件real implementation 的替身,就如同电影中特殊场景需要使用的替身相似。这些test doubles 可以是相对于real implementation 来说较为简单的实作,也可以用来验证系统中特定行为。

前面的章节有提到小型测试的概念,以及为什么测试套件中大部分是由其组成,然而,产品程式码中通常不会都能符合小型测试对于跨程序或是机器的限制,这个时候就需要test doubles 来协助让小型测试能够跑得够快且稳定了。

Test double 在软体开发中的影响

Test double 的使用会给软体开发增加一些复杂度,因此有一些层面需要考量。

可测性

为了要能够使用test doubles,codebase 必须要被设计得的较为可测( testable ) — 需要被设计的让测试能够用test doubles 来替换real implementation。如果撰写程式码时没有将可测性考虑在其中,会导致程式码本身不够有弹性,而当之后要加入测试时,可能会需要非常多的力气重构程式码,以支援test doubles 的使用。

应用性

适当地使用test doubles 非常重要,倘若使用不慎,可能会让测试变得脆弱易碎、复杂、无效。在巨大的codebase 中,这样的缺点会被放大,因此需要谨慎对待。

保真度 ( 诚实 )

Fidelity 指的是test double 与real implementation 的相似程度有多少,如果差太多的话,那这样的test double 可能没有办法提供什么价值。不过,完美的fidelity 不可能存在,不然就没有必要使用test double 而是使用real implementation 就好了。 Test double 通常要比real implementation 要简单得多,来让它能够适合出现在单元测试中。

Google 中的 test doubles

在Google,有无数个test doubles 所带来对于生产力以及软体品质的经验,但同样地也有看过许多滥用test doubles 所造成的负面影响。随着经验逐渐累积,Google 逐渐摸索出一些如何有效率使用test doubles 的指导方针。

过度使用mocking 框架尤其需要特别注意( mocking 框架可以让工程师非常容易地造出test doubles )。一开始当mocking 框架被Google 所使用的时候,它看起来就像万灵丹,让工程师不太需要在乎程式码的dependencies 就能轻易写出集中且专一的测试,但是直到过了许多年才发现许多的问题开始浮现:要维护他们需要花很多时间与心力,却不太能够找出bugs。因此现在许多Google 的工程师开始避免使用mocking 框架,为了要写出更为真实的测试。

基本观念

Test double 的一个范例

想像一个电子商务的网站处理信用卡付款,可能会有类似范例13–1 的程式码。

Example 13–1. A credit card service

如果要在测试中使用真正的信用卡服务是不切实际的,而这个时候就是可以使用test double 的时机了,来模拟真实系统的行为,如范例13–2。

Example 13–2. A trivial test double

尽管这样的test double 看起来没什么用,但其实使用他仍然能允许工程师来测试某些在makePayment 方法里面的逻辑,像是在范例13–3 里面就能够验证该方法在信用卡已经到期时,其行为表现是否正常。

Example 13–3. Using the test double

Seams ( 接缝 )

当程式码是testable,指的是该程式码被写成容易被单元测试的。 接缝 ( 接缝) 是其中一种让程式码变得testable 的方式,透过让SUT 可以使用与在产品程式码中不同的dependencies。

依赖注入 ( 依赖注入) 是一个常常被使用来引入接缝的技巧。简言之,当一个类别使用dependency injection 时,任何一个该类别所使用的依赖都是被传进去,而不是在内部instantiate,这让这些dependencies 可以在测试时被替换掉。

范例13–4 是一个dependency injection 的例子。相比于在constructor 里面建造一个CreditCardService 的instance,这里直接将instance 作为参数传入。

Example 13–4. Dependency injection

如此一来,在真正的产品程式码可以传入能够与外部服务沟通的CreditCardService,而在测试的时候就可以传入test double 作为替代。

Example 13–5. Passing in a test double

撰写testable 的程式码需要的是前期的投资,特别是对于codebase 来说更是如此。因为当可测性越晚被放入考虑时,那么codebase 就越具有可测性。如果程式码在被撰写的时候没有考虑到测试,那么如果之后想要增加适合的测试,就通常需要被重构甚至重写。

Mocking 框架

一个 嘲弄 框架指的是能够在测试中让建立test double 变得容易的软体工具,使用mocking 框架可以减少工程师的心力,因为不需要特地为了测试所需要的test double 额外再定义新的类别。范例13–6 举了一个这样的例子。

Example 13–6. Mocking frameworks

许多主流的程式语言都有mocking 框架,在Google 里面,Java 选用了Mockito,C++ 选用了Googlemock,而Python 选用了unittest.mock。不过需要注意的是,滥用mocking 框架将可能导致程式码库变得更难维护,以下将介绍一些这样的问题。

使用 test doubles 的技巧

常用的test doubles 有三种,而这个章节将会简单介绍这三种的差异。

假装

伪造的 指的是对于某个API 较为轻量级的实作,其行为表现会与real implementation 相似,但却不会在真正的产品中使用。范例13–7 是一个fake 的例子。

Example 13–7. A simple fake

使用fake 通常会是在test doubles 中最理想的技巧,但是一个fake 可能不一定会在想要写测试的时候存在,而且撰写一个fake 是有一点挑战的,因为必须要确保其行为与real implementation 具有相似性。

存根

存根 指的是给予函式一个指定的行为,通常会指定函式回传一个固定的回传值,在范例13–8 即是一个这样的例子。 Stubbing 通常是透过mocking 框架所做到,以减少工程师花时间hardcode 程式码。

Example 13–8. Stubbing

交互测试

交互测试 是一种在没有真正呼叫到真时函式时,所使用的关于函式是 如何 被呼叫的。举例来说,某个测试应该要在某个函式没有被呼叫、被呼叫太多次,或是被呼叫时代有错误的参数。范例13–9 示范了一个interaction testing 的例子。

Example 13–9. Interaction testing

与stubbing 类似,interaction testing 也可以透过mocking 框架来做到。此外,interaction testing 又叫做mocking,不过为了避免读者混淆,在这章节中都会使用interaction testing 来描述。

实际实现

尽管test double 是相当有价值的测试工具,但Google 对于测试的优先选择还是使用real implementation,也就是跟产品程式码中所使用的相同。测试在使用与产品程式码相同的东西时,具有较高的保真度,而使用real implementation 刚好可以达成这点。 Google 发现,过度使用mocking 框架通常会有较高的倾向会让测试受到与real implementation 不相同的污染,进而导致难以重构。

在测试时偏好使用real implementation,称为 经典测试 ;而偏好使用mocking 框架的称为 模拟测试 。尽管有不少人使mockist testing 流派,但Google 认为mockist testing 较难以被扩展。

偏好真实多过于隔离

在测试中使用real implementations 可以让SUT 表现更为真实,相反地,使用test doubles 取代dependencies 的话,可以隔离SUT 与dependencies 之间的关系。我们倾向使用越真实的测试,因为这样的测试可以给予我们更多的信心来相信SUT 确实正常运作;如果单元测试过于依赖test doubles,那么工程师可能还需要跑整合测试或是手动验证那些元件是否如预期地运作,以能够提供同等的信心。做这些额外的工作还会降低开发的速度,而且当工程师过于忙碌时,及有可能没有时间验证这些事情而导致忽略bugs 的出现。

如果real implementations 里面有bugs 的话,那么在测试中使用real implementations 可能会导致测试失败。这当然是好的现象!我们会希望测试会在程式码不如预期地正常运作时,能够告诉我们。

该如何决定什么时候使用real implementation

如果real implementations 可以执行起来快速、稳定、并且只有简单的dependencies 时,那么这样的话绝对是较好的选项。然而,对于更复杂的程式码来说,使用real implementation 可能没那么好执行,这个时候要从下面该考虑的层面中做出取舍。

执行时间

单元测试中最重要的事情之一,莫过于快速。你应该会希望在开发的时候也能够经常执行这些单元测试以得到即时的回馈,这个时候如果real implementations 很慢的话,那么test double 就会变得非常有用。

那么要单元测试要跑多久才算是慢呢? 目前没有办法给出一个精确的答案,因为这与工程师是否感受到开发速度有下降,以及有多少个测试使用real implementations 有关。一般而言,通常使用real implementation 会是个不错的选项,一直到某个时间点大家开始觉得测试跑得越来越慢的时候,就可以用test doubles 来替代了。

Determinism ( 定性 )

如果在版本固定的SUT 中,测试跑完得出的结果总是相同,那代表这个测试是有定性的 确定性的 ,反之则称为 非确定性的

Nondeterminism 在测试中会导致flakiness,也就是测试偶尔会随机性的失败,即便SUT 没有任何的更动,这种现象会让工程师开始不愿意相信测试的结果,进而忽略那些跑失败的测试。如果real implementations 的因素让flakiness 常常常发生,那或许就该是用test doubles 的时机了。这种情况容易出现在real implementations 使用多执行绪而依赖不同执行绪执行的顺序,或是real implementations 需要依赖系统的时钟的时候。

Dependency 建构

当使用real implementation,会需要建立所有的dependencies,而test doubles 通常没有dependencies,因此建立test doubles 会比建立real implementation 容易。

在最极端的例子中,可能会有类似下面这段:

而使用Mockito 使用起来可能会类似这样 :

不过,尽管使用test doubles 会比较简单,但使用real implementation 还是有较多的好处,将在这章节后面介绍到。相对于在测试中手动建立这些物件,理想的解决之道是使用与产品程式码相同建立物件的方式来做,如工厂方法或是自动化的dependency injection。

假装

当使用real implementation 是不可行的时候,最好的选项就是使用一个fake。 Fake 比其他test doubles 好的原因是其与真正实作更为相似,这可以让SUT 难以分辨其互动的对象是real implementation 还是fake 物件。

Example 13–11. A fake file system

为什么fakes 重要

Fakes 是个相当强力的工具,因为他们通常可以执行得相当快速,同时不但不会有使用real implementations 的缺点,还能够允许工程师有效地测试程式码。单单一个fake 就能够大幅的增进测试的效率,那么若能够广泛地运用为数众多的fakes,它所能提供给软体组织的巨大效益是不可言喻的。相反地,如果一个组织里面使用很少的fakes,那么要嘛是使用real implementation 而挣扎于缓慢且不稳的测试,不然就是使用其他的test doubles 技巧而可能间接地让测试不那么有效且易碎。

什么时候需要撰写fakes

Fake 需要花不少的心力来撰写,且需要有该领域的知识,同时也要考量到维护性,倘若real implementations 行为改变时,fake 也会因此需要改变。所以,最好是负责管理real implementations 的团队来负责撰写并维护fake。

如果团队正在考虑是否该撰写fake,那么该思考的是使用fake 所带来的好处是否能超过撰写并维护该fake。如果使用者不多的话,这恐怕不值得做;但若有数百个使用者,那么这将会对产品的开发速度有莫大的提升。

假货的保真度

考量到fake 最重要的一点,应该就是fidelity 了,也就是其行为与real implementations 有多相似。

有时候,追求完美的fidelity 是不切实际的。毕竟,fake 需要存在也是因为real implementations 在某些方面是不适合的。举例来说,一个fake 的资料库在硬碟储存方面就与真正的资料库不相同,因为fake 的资料库通常是将资料储存在记忆体里面。

Fakes 应该要被测试

Fakes 本身当然应该要被测试,才能确保它真的遵守real implementations 所订的API。没有被测试的fakes 在一开始或许确实相符,但随着时间经过,这样的行为可能会逐渐与real implementation 不同。而在对fakes 写测试时,最好是根据API 的公开介面来撰写( 又被叫做 合同测试 )。

如果无法取得fake,那该怎么做

如果无法取得相对应的fake 的时候,可以先去询问API 的拥有者能否建立一个。如果该拥有者不愿意或是没有办法建立fake 的话,你可以自己撰写你自己的一份。在Google,有些团队甚至会将他们所写的fakes 提供给API 的拥有者,来让其他团队也能受益。

关于使用fake 的时机,我们可以把它想像成一个需要权衡的取舍:如果使用real implementation 的测试太缓慢了,我们可以建立fake 来取代他们;但如果fake 跑起来只能提升团队有限的开发速度,而若这样的效益没有超过那些需要建立以及维护fake 的工作,那么采用real implementation 还是较好的选择。

存根

如前面所述,stubbing 指的是hardcode 函式的行为。范例13–12 即是一个使用stubbing 去模拟信用卡服务的回应。

Example 13–12. Using stubbing to simulate responses

滥用Stubbing 的危险

由于stubbing 太容易使用了,因此如果没有那么容易使用real implementations 时,这个选择就相当吸引人,但这并不是一个好的现象。

Stubbing 会需要额外撰写程式码来定义该被stubbed 函式的行为,这会让测试的目的被分心掉,因而使得测试不够清楚,而且对于不熟悉SUT 实作细节的工程师来说,程式码也会变得不容易理解;此外,stubbing 暴露了实作细节,因此当实作细节要被改变时,你就必须要更新相对应的测试,这让测试变得脆弱易碎,因为理想上,一个好的测试只有在与使用者接触的API 行为发生改变时,才需要改变;最后,我们并没有任何方法来确保被stubbed 的函式是否表现起来真的如同real implementations 一样,如下面写死add() 的行为,没办法得到验证,这会让使得测试变得不够有效。

一个过度使用stubbing 的范例

范例13–13 是一个过度使用stubbing 的例子,而范例13–14 在没有使用stubbing的情况下重写了该测试,可以注意到测试变得更短而且实作的细节并没有被暴露在测试当中。

Example 13–13. Overuse of stubbing

Example 13–14. Refactoring a test to avoid stubbing

很明显地我们不想要让这样的测试去连接外部的信用卡服务,所以一个fake 的信用卡服务会是更恰当的。

什么时候该使用stubbing

相对于real implementations 是完完全全的替换,当你只需要某个函式回传特定值来让SUT 处于特定状态,就非常适合stubbing。由于函式的行为只会在测试中被定义,因此stubbing 可以模拟各种的回传值,甚至是real implementations 或是fake 不容易模拟产生的错误。但需要注意的是,每一个测试中通常不应该有太多的stubbing,因为过多的stubbing 可能会造成测是变得不清楚。一个测试中有过多的stubbed 函式也可能暗示该SUT 太过复杂而需要重构。

此外,尽管有些情境相当适合stubbing,real implementations 或是fakes 仍然是优先选择,因为他们不会暴露出实作细节,同时也更能确保程式码确实正常运作。

交互测试

如同前面所述, 交互测试 是一种在不用呼叫到真正的函式本身,而做到能够验证函式是如何被呼叫的。 Mocking 框架让这件事情变得相当容易做到,然而,根据经验显示,如果要让测试具有可读性、有效性以及有能力应对变更的话,最好在必要的时候才使用这项技巧。

优先选择state testing 相对于interaction testing

相对于interaction testing,测试的时候更倾向用state testing。在state testing 中,开发者会呼叫SUT 并且验证回传值或是系统的状态是否有如预期地被改变,如范例13–15。

Example 13–15. State testing

比较之下,范例13–16 是一个用interaction testing 的方式所撰写的测试。注意到,其实根本就无法知道该测是根本无法知道数字到底有没有被排序成功,因为test doubles 并不知道如何排序数字,唯一能知道的是SUT 的确有尝试着想要排序。在Google 我们认为state testing 较具有可扩性,能够减少测试的易碎性,使其更容易改动并且容易维护。

Example 13–16. Interaction testing

Interaction testing 最大的问题在于,它只能验证特定的函式是否被如期的呼叫,而不能检查系统SUT 到底是否顺利运作。举例来说,当呼叫database.save(item) 的话,应该可以预期该item 有被存入资料库中,而state testing 可以验证这种假设,但interaction testing 不行。

另一个interaction testing 的缺点在于,它会使用到SUT 的实作细节。在测试中使用暴露出产品程式码的实作细节,会让测试变得较为脆弱易碎。在Google 中甚至有些人开玩笑地说,过度使用interaction testing 就是一种 变化检测器测试 s,因为这样的测试禁不起任何一点产品程式码的改动。

什么时候适用interaction testing

尽管其缺点,但有些情境确实适用interaction testing。

  • 没办法使用real implementation 与fake 等等来做到state testing 的时候。这种情况下,虽然使用interaction testing 不是非常理想,但多少能够提供一点保护作用。
  • 当呼叫函式时,次数或是顺序不同将会造成超出预期的行为的时候。这时interaction testing 就非常管用,因为state testing 不容易验证这种情形。像是当你为了减少呼叫database 的次数,而对系统增加一个快取的功能,就可以透过这种方式来验证

Interaction testing 不必然是state testing 的补集。如果在单元测试中没有办法使用state testing,那么其实可以考虑在测试套件中增加较大范围的测试来达成state testing。大范围的测试是个降低风险的重要策略,将在下一章节中介绍。

Interaction testing 的最佳实践

尽量对state-changing 的函式进行interaction testing

  • State-changing 函式 ,如:sendEmail(), saveRecord(), logAccess()
  • Non-state-changing 函式 ,如:getUser(), findResults(), readFile()

通常对于non-state-changing 函式进行interaction testing 较为多余且让测试变得脆弱,因为这样的interaction 不会产生side effect,因此不会是需要强调正确性的重点细节。从范例13–17 可以看出两者的差异。

Example 13–17. State-changing and non-state-changing interactions

避免 overspecification

前一章节有提到,测试一个函式的时候应该要专注在验证某个行为,而不应该要在一个测试中验证多种行为。同样地,在interaction testing 中仍然是类似的道理,避免过度指定函式以及参数,这样可以让测试变得较为清楚且较能承受改变。范例13–18 演示了一个overspecification 的例子,这个测试想要验证的是使用者的名称是否被包含在打招呼的服务中,但该测试会在不相关的行为发生改变时一样无法通过。

Example 13–18. Overspecified interaction tests

范例13–19 则是更进一步将相关的参数考虑得更加仔细,该被测试的行为被分散成不同的测试项目,而每一个测试只验证非常小范围的必要程度。

Example 13–19. Well-specified interaction tests

结论

在这章节中,我们看到了test doubles 对于工程是多么重要的存在,他们能够协助工程师测试程式码,并确保测试跑得足够快且稳定;然而,错误的使用这项技巧同样也会造成开发上的瓶颈,让测试变得不够清楚、脆弱以及无效。因此,了解如何有效地使用test doubles 对于工程师来说至关重要。使用test doubles 的时机以及该使用哪项技巧,仰赖着工程师明智的判断与取舍,才能发挥最大效益。

尽管test doubles 是个解决dependencies 的厉害手段,但是如果工程师想要对程式码产生更多的信心,那么迟早还是要将这些dependencies 纳入测试之中,这将会在下一章节中有进一步的讨论。

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明

本文链接:https://www.qanswer.top/37224/15221710

posted @   哈哈哈来了啊啊啊  阅读(173)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
点击右上角即可分享
微信分享提示