第7章 测试

 
7.1测试类型
 
作为一名顾问,我喜欢使用形式各异的象限来对世界进行分类。起初,我以为这本书不会 有这样的象限。幸运的是,Brian Marick想出了一个非常棒的分类测试体系,恰好就是用 象限的方式。图7-1展示了 Lisa Crispin和Janet Gregory在《敏捷软件测试》一书中,用来 将不同测试类型分类的测试象限,这个象限是Matrick的演化版本。
图7-1: Brian Marick的测试象限。出自《敏捷软件测试》第1版,经过Pearson出版社的许可进行 了修改
 
处于象限底部的是面向技术的测试,即那些首先能够帮助开发人员构建系统的测试。这个 象限里面的测试大都是可以自动化的,例如性能测试和小范围的单元测试。相对而言,处 于象限顶部的测试则是帮助非技术背景的相关人群,了解系统是如何工作的。这种测试包 括象限左上角的大范围、端到端的验收测试,还有象限右上角的由用户代表在UAT系统 上进行手工验证的探索性测试。
 
在这个象限中,每种测试类型都有自己相应的位置。在不同系统中,每种类型的测试占比 是有差別的。重点是要理解你在测试方面可以有不同的选择。放弃大规模的手工测试,尽 可能多地使用自动化是近年来业界的一种趋势,对此我深表赞同。如果当前你正在使用大 量的手工测试,我建议在深入微服务的道路之前,先解决这个问题,否则很难获得微服务 架构带来的好处,因为你无法快速有效地验证软件。
 
鉴于本章的目的,我们将忽略手工测试。手工测试是很有用的,也肯定有它存在的必要。 不过,测试微服务架构的系统跟测试独立系统的区别,很大程度上在于各种类型的自动化 测试。因此,我们将集中精力在自动化测试上面。
 
那么,每种类型的自动化测试需要多大的比例呢?另一种模型在帮助我们回答这个问题上 非常有用,并且有助于了解不同测试的优缺点。
 
7.2测试范围
 
Mike Cohn在他的《Scrum敏捷软件开发》一书中介绍了一种叫作“测试金字塔”的模型, 其中描述了不同的自动化测试类型。这个金字塔模型不仅可以帮助我们思考不同的测试类 型应该覆盖的范围,还能帮助我们思考应该为这些不同的测试类型投人多大的t比例。如图 7-2所示,Cohn在他的原始模型中把自动化测试划分为单元测试、服务测试和用户界面测 试三层。
图7-2: Mike Cohn的测试金字塔。出自Mike Cohn的《Scrum敏捷软件幵发》第1版,经过 Pearson出版社的许可进行了修改
 
这个原始模型存在一个问题:不同的人对这些术语有不同的解读。尤其是“服务”这个词 经常有各种不同的解读,而单元测试也有很多定义。只测试一行代码是单元测试吗?我会 说是。那测试多个函数或者多个类仍然是单元测试吗?我会说不是,不过很多人并不同 意!从现在开始,尽管单元测试和服务测试这两个名称有歧义,我还是继续使用它们。不 过对于用户界面测试,接下来我们改称它为端到端测试。
 
考虑到大家的困惑,下面我们解释一下金字塔各层所代表的含义。
 
让我们用一个示例来解释。图7-3中包含帮助台和Web客户端,这两个客户端程序都通 过与客户服务的交互来获取、预览和编辑客户的详细信息。接下来我们的客户服务会与积 分账户交互。在积分账户中,客户可以通过购买贾斯汀•比伯的CD来累积积分。很显然, 这只是整个音乐商店系统的一小部分,但它足以让我们深入到几个不同的场景来思考如何 测试。
图7-3:待测试的部分音乐商店系统
 
7.2.1单元测试
 
单元测试通常只测试一个函数和方法调用。通过TDD (Test-Driven Design,测试驱动开 发)写的测试就属于这一类,由基干属性的测试技术所产生的测试也属于这一类。在单元测试中,我们不会启动服务,并且对外部文件和网络连接的使用也很有限。通常情况下你 需要大量的单元测试。如果做得合理,它们运行起来会非常非常快,在现代硬件环境上运 行上千个这种测试,可能连一分钟都不需要。
 
在Marick的术语中,单元测试是帮助我们开发人员的,是面向技术而非面向业务的。我 们也希望通过它们来捕获大部分的缺陷。因此,如图7-4所示,在我们示例的客户服务中, 单元测试是彼此独立的,分别覆盖一些小范围的代码。
图7-4:示例中的单元测试范围
 
这些测试的主要目的是,能够对于功能是否正常快速给出反馈。单元测试对干代码重构非 常重要,因为我们知道,如果不小心犯了错误,这些小范围的测试能很快做出提醒,这样 我们就可以放心地随时调整代码。
 
7.2.2服务测试(相当于服务内的集成测试)
 
服务测试是绕开用户界面、直接针对服务的测试。在独立应用程序中,服务测试可能只测 试为用户界面提供服务的一些类。对于包含多个服务的系统,一个服务测试只测试其中一 个单独服务的功能。
 
只测试一个单独的服务可以提高测试的隔离性,这样我们就可以更快地定位并解决问题。 如图7-5所示,为了达到这种隔离性,我们需要给所有的外部合作者打桩,以便只把服务 本身保留在测试范围内。
图7-5:示例中的服务测试范围
 
一些服务测试可能会像单元测试一样快,但如果你在测试中使用了真实的数据库,或通过 网络跟打桩的外部合作者交互,那么测试时间会增加。服务测试比简单的单元测试覆盖的 范围更大,因此当运行失败时,也比单元测试更难定位问题。不过,相比更大范围的测 试,服务测试中包含的组件已经少多了,因此也没大范围的测试那么脆弱。
 
7.2.3端到端测试
 
端到端测试会覆盖整个系统。这类测试通常需要打幵一个浏览器来操作图形用户界面 (GUI),也很容易模仿类似上传文件这样的用户交互。
 
正如图7-6所示,这种类型的测试会覆盖大范围的产品代码。因此,当它们通过的时候你 会感觉很好,会确定这些被测试过的代码在生产环境下也能工作。但是待会儿就会看到, 伴随着覆盖范围的增大,一些在使用微服务过程中很难消除的负作用也会随之而来。
图7-6:示例中的端到端测试范围
7.2.4权衡
 
在使用这个金字塔时,应该了解到越靠近金字塔的顶端,测试覆盖的范围越大,同时我们 对被测试后的功能也越有信心。而缺点是,因为需要更长的时间运行测试,所以反馈周期 会变长。并且当测试失败时,比较难定位是哪个功能被破坏。而越靠近金字塔的底部,一 般来说测试会越快,所以反馈周期也会变短,测试失败后更容易定位被破坏的功能,持续 集成的构建时间也很短。另外,还能避免我们在不知道已经破坏了某个功能的情况下转去 做新的任务。这些更小范围的测试失败后,我们更容易定位错误的地方,甚至经常能定位 到具体哪行代码。从另一个角度来看,当只测试了一行代码时,我们又很难有充足的信心 认为,系统作为一个整体依然能正常工作。
 
当范围更大的测试(比如服务测试或者端到端测试)失败以后,我们会尝试写一个单元测 试来重现问题,以便将来能够以更快的速度捕获同样的错误。我们通过这种方式来持续地 缩短反馈周期。
 
事实上,我曾经待过的所有团队使用的测试类别名称都跟Cohn在金字塔中使用的不完全 相同。不过,不管怎么称呼它们,测试金字塔的关键是,为不同目的选择不同的测试来覆盖不同的范围。
7.2.5比例
 
既然所有的测试都有优缺点,那每种类型需要占多大的比例呢? 一个好的经验法则是,顺 着金字塔向下,下面一层的测试数量要比上面一层多一个数量级。如果当前的权衡确实给 你带来了问题,那可以尝试调整不同类型自动化测试的比例,这是非常重要的!
 
举个例子,我曾在一个单块系统上工作过,这个系统有4000个单元测试、1000个服务测 试和60个端到端测试。我们发现测试的反馈周期很长,其原因在于有太多的服务测试和 端到端测试(后者是反馈周期变长的罪魁祸首),之后我们便尽量使用小范围的测试来替 换这些大范围的测试。
 
一种常见的测试反模式,通常被称为测试甜筒或倒金字塔。在这种反模式中,有一些甚至 没有小范围的测试,只有大范围的测试。这些项目的测试运行起来往往极度缓慢,反馈周 期很长。如果把这些缓慢的测试作为持续集成的一部分,那就很难做到多次构建。而长时 间的构建也意味着当提交有错误时,需要很长一段时间才能发现这个问题。
 
7.3实现服务测试
 
在自动化测试的所有类型中,单元测试是比较简单的,相关的资料也非常多。而服务测试 和端到端测试的实现则要复杂得多。
 
服务测试只想要测试一个单独服务的功能,为了隔离其他的相关服务,需要一种方法给所 有的外部合作者打桩。如果想要测试像图7-3那样的客户服务,我们需要部署这个服务的 实例,然后给它所有的下游合作服务打桩。
 
构建是持续集成的第一步,它会为服务创建一个二进制的包,所以部署服务很明确。不过 我们该如何给下游的合作者打桩呢?
 
对于每一位下游合作者,我们都需要一个打桩服务,然后在运行服务测试的时候启动它们 (或者确保它们正常运行)。我们还需要配置被测服务,在测试过程中连接这些打粧服务。 接着,为了模仿真实的服务,我们需要配置打桩服务为被测服务的请求发回响应。例如, 我们可以配置积分账户为不同的客户返回不同的预设积分。
 
7.3.1 mock还是打粧    。
 
打桩,是指为被测服务的请求创建一些有着预设响应的打桩服务。比如我可能会设置积分 账户,当有请求询问客户123的余额时,它应该返回15 000。这时候的测试不关心这个打 桩服务被访问了 0次、1次还是100次。另一种替换打桩的方式是mock。
 
与打桩相比,mock还会进一步验证请求本身是否被正确调用。如果与期望请求不匹配, 测试便会失败。这种方式的实现,需要我们创建更智能的模拟合作者,但过度使用mock 会让测试变得脆弱。相比之下,前面提到的打桩并不在乎请求发生了0次、1次还是很 多次。
 
有时候可以用mock来验证预期的副作用是否发生。例如,可以使用mock来验证创建一 个客户后,与其相关的积分余额是否也被创建。无论在单元测试还是在服务测试中,使用 打桩还是mock都是很微妙的选择。不过一般来说,我在服务测试中使用打桩的次数要远 远超过使用mock的次数。关于如何权衡两者的更深入的讨论,大家可以参考弗里曼和普 雷斯的书《测试驱动的面向对象软件开发》。
 
通常,我使用mock的次数不多。不过有一个能够同时支持mock和打桩的工具还是很有 用的。
 
在我看来,打桩和mock之间的区别很明显。不过据我了解,很多人会感到困惑,特别是 当再引入其他诸如fakes、spies和dummies这些术语时。Martin Fowler把包括打桩和mock 在内的所有这些术语统称为测试替身(Test Double,http://www.martinfowler.com/bliki/ TestDouble.html)。
 
7.3.2智能的打桩服务
 
以前我都是自己创建打桩服务。为了启动测试需要的打桩服务,我尝试过Apache、Nginx 和嵌入式Jetty容器,甚至还使用过命令行启动Python的Web服务器。这样的工作我 曾经重复做了很多次。我在ThoughtWorks的同事Brandon Bryars,创建了一个叫作 Mountebank (http://www.mbtest.org/)的打桩/mock服务器,它帮助了很多人避免像我那样 重复工作多次。
 
可以把Mountebank看作一个通过HTTP可编程的小应用软件。虽然它是用Node.js编写 的,但对调用它的服务来说这完全是透明的。当启动后,你可以发送命令告诉它需要打桩 什么端口、使用哪种协议(目前支持TCP、HTTP和HTTPS,未来会支持更多)以及当收 到请求时该响应什么内容。当你想把它当mock来使用时,它还支持对预期行为的设置。 你可以在Momebank的一个实例上,很方便地添加或删除打桩接口,这样就可以使用一个 实例来打桩多个下游的合作服务。
 
所以,在运行客户服务的服务测试时,我们需要启动客户服务本身外加一个Mountebank 的实例来作为积分账户的替身。如果这些测试通过,我立马就可以部署客户服务啦!等 等,真的可以吗?那些调用客户服务的服务(比如帮助台和网络商店)怎么办?我们更新 的内容是否会影响它们?是的,我们差点忘记了,测试金字塔顶部还有一个非常重要的测 试:端到端测试。
 
 
7.4微妙的端到端测试(相当于系统测试-所有服务的整体测试)
 
在微服务系统中,界面展示的一个功能往往涉及多个服务。Mike Cohn在金字塔中引入端 到端测试,关键是想通过这种用户界面的测试覆盖其涉及的所有服务,从而帮助我们了解 系统的概况。
 
所以,运行端到端测试需要部署多个服务。显然,这种测试可以覆盖更大的范围,也让我 们对系统的正常工作更有信心。另一方面,这种测试运行起来比较慢,定位失败也更加困 难。为了更深入地理解这些优缺点,我们来看看前面的例子是如何体现这些的。
 
假设我们开发了客户服务的一个新版本。我们想尽快把新版本部署到生产环境,但又担心 引入的某些变化会破坏帮助台或者网络商店的功能。没问题,让我们部署所有的服务,然 后对帮助台和网络商店运行一些端到端测试来验证是否引入了缺陷。一个不成熟的方案 是,直接在客户服务流水线的最后增加这些测试,如图7-7所示。
图7-7:加在客户服务流水线的最后:这种方式正确吗?
 
到目前为止还好。但我们首先需要问自己一个问题:应该使用其他服务的哪个版本?是否 应该使用与生产环境相同的帮助台和网络商店版本?这是一个合理的假设,但是如果帮助 台或网络商店也有新的版本准备上线,那该怎么办呢?
 
另一个问题是,如果说客户服务的测试需要部署多个服务,然后运行端到端测试来覆盖, 那么其他服务的端到端测试该怎么办?如果它们也测试同样的功能,就会发现这些测试有 很多的重叠,而且需要在运行测试前花费大量的成本来重复部署这些服务。
 
解决这两个问题的一种优雅的方法是,让多个流水线扇入(fan in)到一个独立的端到端测 试的阶段(stage)。使用这种方法,任意一个服务的构建都会触发一次端到端测试,如图 7-8所示。一些更好地支持构建流水线的CI工具可以很方便地实现这样的扇入模型。
图7-8:覆盖多个服务的端到端测试的一种标准方式
 
这样,任意一个服务在任何时候只要发生变化,我们都会运行针对这些服务的测试。如果 测试通过,便会触发端到端测试。这个方法听起来很棒,不是吗?不过,这样做还是会有 一些问题。
 
7.5端到端测试的缺点
 
遗憾的是,端到端测试有很多的缺点。
 
7.6脆弱的测试
 
随着测试范围的扩大,纳入测试的服务数量也会相应地增加。这些服务有可能会使测试失 败,而这种失败并不是因为功能真的被破坏了,而是由其他一些原因引起的。举例来说, 如果我们有一个测试来验证订购CD的功能,这个功能涉及四到五个服务,其中任意一个 服务停止运行都会导致测试的失败,但这种失败与被测的功能本身没有关系。同样,一个 临时的网络故障也可能导致测试失败,这也跟被测的功能本身没有关系。
 
包含在测试中的服务数量越多,测试就会越脆弱,不确定性也就越强。如果测试失败以后 每个人都只是想重新运行一遍测试,然后希望有可能通过,那么这种测试是脆弱的。不仅 这种涉及多个服务的测试很脆弱,涉及多线程功能的测试通常也会有问题,测试失败有时 是因为资源竞争、超时等,有时是功能真的被破坏了。脆弱的测试是我们的敌人,因为这 种测试的失败不能告诉我们什么有用的信息。如果所有人都习惯干重新构建CI,以期望刚 失败的测甙通过,那么最终结果只能是看到堆积的提交,然后突然间你会发现有一大堆功 能早已经被破坏了。
 
当发现脆弱的测试时,我们应该竭尽全力去解决这个问题。否则,人们就会开始对测试套 件失去信心,因为它们“总是这样失败”。一个包含脆弱测试的测试套件往往会成为DianeVaughn所说的异常正常化(the normalization of deviance)的受害者,也就是说,随着时
 
间的推移,我们对事情出错变得习以为常,并开始接受它们是正常的。7因为人类有这种倾 向,所以在开始接受失败测试是正常的之前,应该尽快找到这些脆弱的测试并消除它们。
 
在 “Eradicating Non-Determinism in Tests”  ( http://martinfowler.com/aiticles/nonDeterminism.html 这篇博文中,Martin Fowler建议发现脆弱的测试时应该立刻记录下来,当不能立即修 复时,需要把它们从测试套件中移除,然后就可以不受打扰地安心修复它们。修复时,首 先看看能不能通过重写来避免被测代码运行在多个线程中,再看看是否能让运行的环境更 稳定。更好的方法是,看看能否用不易出现问题的小范围测试取代脆弱的端到端测试。有 时候,改变被测软件本身以使之更容易测试也是一个正确的方向。
 
7.6.1谁来写这些测试
 
既然这些测试是某服务流水线的一部分,一个比较合理的想法是,拥有这些服务的团队应 该写这些测试(我们将在第10章进一步讨论服务所有权的话题)。但是需要考虑当一个服 务涉及多个团队,而且端到端测试也被多个团队共享时,谁该负责实现和维护这些测试?
 
我曾经见过很多反模式。一种情况是,这些测试对所有人开放,所有团队成员都可以在无 须对测试套件质量有任何理解的情况下随意添加测试。这往往会导致测试用例爆炸,有时 甚至会导致我们前面谈到的测试甜筒。我还曾经看到过这样的情况,因为测试没有真正的 拥有者,所以它们的结果会被忽略。当测试失败后,每个人都认为是别人的问题,大家根 本不在乎测试是否通过。
 
有些组织的答案是由一个专门的团队来写这些测试。这可能是灾难性的。开发软件的人渐 渐远离测试代码,周期时间(cycle time)会变长,因为服务的拥有者实现功能需要等待测 试团队来写端到端测试。因为这些测试由别的团队编写,实现服务的团队很少参与,所以 很难了解如何运行和修复这些测试。很不幸,这是一个非常常见的组织模式,只要团队没 有在第一时间测试自己所写的代码,就会出现很大的问题。
 
在这方面做到正确真的很难。我们不想做重复的工作,也不想过度集权,比如让测试远离 实现服务的团队。我发现最好的平衡是共享端到端测试套件的代码权,但同时对测试套件 联合负责。团队可以随意提交测试到这个套件,但实现服务的团队必须全都负责维护套件 的健康。如果你想在多个团队中大范围地使用端到端测试,这种方法是必要的,然而我看 到的团队很少这样做,所以存在的问题也很多。
 
7.6.2测试多长时间 
 
运行这些端到端测试需要很长时间。我见到过至少需要运行整整一天的测试。在我曾经做过的一个项目中,运行一套完整的回归测试需要六个星期!实际上,我很少看到团队精细 地管理端到端测试套件、减少重复覆盖的测试或花足够的时间让它们变快。
 
运行缓慢和脆弱性是很大的问题。一个测试套件需要花整整一天时间来运行,然后经常有 与功能破坏无关的测试失败,这就是个灾难。即使真的是功能被破坏了,也需要花很长时 间才能发或,而此时大家已经开始转做其他的事情了,切换大脑的上下文来修复测试是很 痛苦的。
 
并行运行测试可以改善缓慢的问题。可以使用Selenium Grid等工具来达到这个效果。然而 这种方法并不能代替去真正了解什么需要被测试,以及哪些不必要的测试可以被删掉。
 
删除测试往往令人担忧,我怀疑这与想要移除机场的某些安保措施有共通点。无论安保措 施多么无效,当你想要移除它们时,人们都会下意识地认为这是无视人们的安全,或想要 帮助恐怖分子。很难在增加的价值和承受的负担之间寻求平衡。这是一个困难的风险/回 报权衡。当你删除一个测试时,会有人感谢你吗?也许吧。不过,如果因为你删除的测试 而漏掉一个缺陷,你肯定会被指责。然而在处理覆盖范围广的测试套件时,删除测试是非 常有用的。如果相同的特性在20个不同的测试中被覆盖,而运行这些测试需要10分钟, 也许我们可以删掉其中的一半。如果要这样做,你需要更好地理解风险,而这刚好是人类 所不擅长的。结果就是,你很少能见到有人能够精细地对大范围、高负担的测试进行管理 和维护。希望它发生与真正让它发生是不一样的。
 
7.6.3大量的堆积
 
端到端测试的反馈周期过长,不仅会影响开发人员的生产效率,同时任何失败的修复周期 也都会变长,这也就不可避免地减少了端到端测试通过的次数。如果只有在所有测试通过 的前提下才能部署软件(你应该这么做),那么服务被部署的次数也会减少。
 
这可能会导致大量的堆积。在修复失败的端到端测试的同时,上游团队一直在提交更多的 变更。结果是,除了使修复构建更加困难外,要部署的变更内容也多了。解决这个问题的 一个方法是,端到端测试失败后禁止提交代码,但考虑到测试套件的运行时间过长,这个 要求通常是不切实际的。试想一下这样的命令:“你们30个开发人员在这个耗时7小时的 构建修复之前不准提交代码! ”
 
部署的变更内容越多,发布的风险就会越高,我们就越有可能破坏一些功能。保障频繁发 布软件的关键是基于这样的一个想法:尽可能频繁地发布小范围的改变。
 
7.6.4元版本
 
在端到端测试阶段,人们很容易有这样的想法:我知道所有服务在这些版本下能够一起工 作,为什么不一起部署它们呢?这个对话很快会演化成:为什么不给整个系统使用同一个版本号呢?引用 Brandon Bryars (http://martinfowler.com/articles/enterpriseREST.html)的话: “现在2.1.0有问题了。”
 
为应用于多个服务上的修改使用相同的版本,会使得我们很快接受这样的理念:同时修改 和部署多个服务是可以接受的。这个成了常态,成了正常的情况。而这样做后,我们就会 丢弃微服务的主要优势之一:独立于其他服务单独部署一个服务的能力。
 
把多个服务一起进行部署经常会导致服务的耦合。不用很长时间,本来分离得很好的服务 就会与其他服务纠缠得越来越紧密,而你可能从未注意到,因为从未试图单独部署它们。 最终,系统杂乱无序,你必须同时部署多个服务。正如我们前面所讨论的,这种耦合会使 我们处于比使用一个单块应用还要糟糕的地步。
 
这情况太糟糕了。
 
7.7测试场景,而不是故事
 
尽管有如上所述的缺点,但对许多用户来说,覆盖一两个服务的端到端测试还是可管理 的,也是有意义的。但覆盖3个、4个、10个或20个服务的测试怎么办?不用多长时间, 这些测试套件便会变得非常臃肿,而在最坏的情况下,这个测试场景甚至可能会出现笛卡 儿积式的爆炸。
 
如果我们掉进陷进,为每一个新添加的功能增加一个新的端到端测试,那么这种情况会加 剧恶化。当你给我展示每实现一个新的故事便添加一个新的端到端测试的代码库时,我将 向你展示一个臃肿的测试套件、很长的反馈周期和巨大的重叠测试覆盖率。
 
解决这个问题的最佳方法是,把测试整个系统的重心放到少量核心的场景上来。把任何在 这些核心场景之外的功能放在相互隔离的服务测试中覆盖。团队之间需要就这些核心场景 达成一致,并共同拥有。对于音乐商店来说,我们可能会专注于像购买CD、退货或创建 一个客户等高价值的交互,它们的数量应该很少。
 
通过专注于少量(“少量”的意思是即使对于一个复杂系统来说,也应该是非常低的两位 数)的测试,我们可以缓解端到端测试的缺点,但并不能避免所有的缺点。还有更好的方 法吗?
 
 
posted @ 2019-12-05 21:35  mongotea  阅读(208)  评论(0编辑  收藏  举报