Microservices陷阱: 测试

层级良好的测试是保证持续集成/交付的前提。对微服务而言,随着系统复杂性的提高,原有测试层级会遭遇挑战,本文将在澄清现有基本概念的基础上讨论这一问题。

1.测试自动化

《Agile Testing》的测试四方图帮我们理清了测试开发的基本类型:验收测试、探索测试、单元测试和性能测试。当然这既包括手动、也包含自动化测试,这里本文只关注后者,但二者的地位应是同等重要的。

Mike Cohn在《Succeeding with Agile》中介绍了自动化测试的金字塔图,书中的金字塔把自动化测试划分为三种类型,从底向上分别是单元测试、服务测试和UI测试。其中,服务测试在大多数情况下指集成测试,而UI测试目前更多被称作End-to-End测试。关于这三种测试的概念这里就不再强调,但实践中更多会面临各自权重的问题。

测试权重

测试金字塔给出了以下结论:当层级越高,测试的范围越大,质量保证的可靠性也越高,但是反馈时间也越久,调试难度越大;层级越低则这种趋势会完全相反。因此,合理安排测试量是一个非常重要的问题。但应强调一点,上述测试并非是一次促成,固定不变的。例如服务/UI测试一旦出现问题,最便捷的解决方法是引入更多单元测试去覆盖上述边界条件。

但是无论如何也不要陷入误区:即使你有更多时间去完善UI测试,也不要忽视金字塔量级的规律。一种常见的反模式是测试量呈倒金字塔状,过度依赖UI测试,而忽略了单元测试。因为这么做忽略了反馈周期的因素,随着UI测试反馈周期的不断增加,必然降低整个团队的效率,也不利于代码质量的保证。

实现服务测试

微服务架构下的服务测试显得尤为重要,但实际上也面临集成的问题,特别是CI架构方面。例如采用CI实时启动一个构建好的消费者服务,或是直接在测试代码中stub——但需要花费额外代价去模拟stub的功能。提到stub,可能会引出mock的问题,后者关注行为,实际上是面向边际效应的一种测试手法,《Growing Object-Oriented Software, Guided by Tests》详细比较了上述二者。当然Martin Fowler的TestDouble也是一个直观的介绍。

现今的服务测试工具已多如牛毛,其基本原理还是stub一个消费者服务,运行测试前启动该服务即可。其趋势已是跨平台、跨语言了(参考Mountebank)。

微服务和UI测试

UI测试本身具有一定复杂度,更别说和微服务架构集成了。如果采用单一系统的做法,需要在CI上分别部署全部服务,然后触发UI测试——这不仅仅是花钱的问题,更意味着时间成本的增加。

UI测试中普遍存在某些代码片段,是时断时续的,这就导致开发人员可能会不断重复运行测试,因为这可能是一个随机产生的现象。对于这种不确定性的Flaky测试,应当尽量移除出代码库。Martin Fowler在Eradicating Non-Determinism in Tests中详细讨论了UI测试中存在的这类问题,并给出了一些解决方案。

另一个问题是UI测试的ownership分配。一般情况下,单一系统可能是全部团队成员维护一个UI测试代码库。但是这会引发测试健康值下降的问题,有时会派QA监控这一问题。而对微服务而言,UI测试可能是跨团队的,因此必须限制测试代码的提交权限,以及明确ownership。最多应覆盖相关的服务开发团队。

当然,微服务带来的UI测试爆炸也是一个比较严重的问题。尽管已经有一些并发框架例如Selenium Grid减少时间的浪费,但这些并不能实际解决测试爆炸的问题。终极方法可能还是精简当前测试部署,当然这涉及到UI测试的代码架构。

发布堆积

由于微服务的相互隔离性,一旦UI测试失败,在某个源头微服务修复前,其它微服务将无法被正确部署,从而导致发布包产生堆积现象。一种解决方法是一旦UI测试失败,停止所有的代码提交——当然这并不容易,特别是当变更较大时,修复的成本较高,可能导致全部团队效率的下降。因此保证小变更的频繁提交是一个有效减小风险的实践。

到这里你可能会对微服务产生质疑,既然End-to-End测试通过后才能发布,岂不意味着某一批版本的微服务将被同步发布?那么,这种部署方式还遵循微服务的独立部署原则吗?实际上,尽管可能把End-to-End测试作为一个限值,但微服务的独立性并没有遭到破坏,如果采用同一个发布版本,无意间就意味着微服务间的耦合性在增加,反而失去微服务本身的优势。因此保持平衡十分重要。

2.测试代码架构

当测试代码开始融入codebase,就要考虑设计的问题了——因为你不得不去考虑功能代码中遇到的同样问题。但由于根本目的不同,测试代码的设计存在不一样的可能。

用户轨迹,而非用例

对于UI测试,按照敏捷的一般实践,可能很自然地实现针对每一个story的测试。面向用例的UI测试带来的是大量重复性测试,这显然不利于控制UI测试的成本。另外,从探索测试的角度来说,遵循普通用户轨迹的UI测试可能才是接近真实情况的。因此,UI测试的组织应尽量面向用户轨迹,特别是核心功能的用户轨迹。

消费者驱动测试和Pact

前文已经提到,基于stub/mock的服务集成测试已经比较常见,但是,尚没有一个能够保证集成测试尽快响应代码变更的机制。例如,当某个服务发生变更时,就需要考虑修改其它服务实现的它的stub,否则就会降低测试结果的可靠性。一种方法被称作消费者驱动的契约测试,其基本出发点是开发能满足消费者需求的服务,同时尽可能保证实时同步相互之间的变更。其基本过程就是由消费者一方提出需求,并构建契约文本,再与服务提供方达成一致,实现相关功能。

Pact是一个开源的基于消费者驱动的测试工具,分别基于Ruby、Java和.Net。而Pact生成的契约文本通常是JSON格式,这就形成了跨功能的契约传递。Pact生成的契约可以通过任何形式存储,例如CI/CD的交付物、或Pact Broker版本管理工具,后者允许服务提供者能够同时对多个版本的消费者服务运行集成测试。另一个工具Pacto,实际上记录了服务间的交互过程,并形成消费者驱动的测试,比起Pact它更为静态化,而Pact则被嵌入到测试内容中,随着功能变更实时变化。

集成测试和UI测试

那么问题来了,前文提到UI测试是一项非常耗成本的工作,针对微服务尤为如此。随着集成测试的有效引入,是否就意味着降低甚至移除UI测试?答案是未必。

UI测试能够让许多问题在发布前最后一刻暴露出来,从而避免灾难的发生。因此,在发布时间不是非常紧急的情况下,运行UI测试反而能够降低人工成本。如果发布在即,可能来不及运行UI测试,那么撰写UI测试可能就显得没有特别必要了?现在针对微服务架构已经有一种做法,直接在生产环境中运行UI测试,而整个测试过程需要通过语义化监控技术记录全程(关于监控和语义化监控,我们会在下一篇文章中介绍)。

3.发布后测试

如果测试只在发布前进行,当出现线上bug,测试可能就显得无能为力,只能待开发人员修复功能后,预上线环境重测。问题的本质在于,测试很难完整模拟用户行为,特别是你永远都不完全了解用户是怎样使用系统的。为了尽早发现bug,在发布后测试是一个有效方法。

一次发布,分别部署

我们已经知道,微服务建议独立部署各自的服务,那么在发布前针对单一服务的测试可能显得无力。如果先部署服务,再运行针对该服务的测试,就能快速发现很多问题。这种测试方式的一个典型案例就是“冒烟测试”,即快速运行针对新部署服务的测试。

一个稍复杂的例子是蓝/绿部署。这种形式下,系统存在两份相同的拷贝,但只有其中一个真正接收外部请求。例如现有正常服务A,当更新A+发布时,先将A+部署到另一个环境,运行冒烟测试,通过后再把生产环境的流量导入到A+。该做法还能保证即使出现更多失误,也能快速回滚代码。然而,蓝/绿部署需要额外的成本,首先你需要大量环境容纳各种版本,并且能快速切换流量(基于DNS或LBS),当你采用弹性云服务实现这些就很方便。

金丝雀发布

相比蓝/绿部署,金丝雀发布则更为强大(要求也更高)。在这种发布形式下,新服务部署后,会由导流工具引入一部分生产环境中的流量,然后通过比较两个共存版本间的各种监控数据来保证功能的正确性,一旦新服务的出错率明显上升,则切断该部分的路由,反之则切断原有服务的路由。金丝雀发布的生产效率更高,但技术要求也更多,特别是针对幂等规则下的请求/响应通信,如何实现无缝导流会是一个难题。

MTTR和MTBF

MTTR指平均修复时间,MTBF指平均错误间隔时间,这两种概念实际上体现了不同的运维策略。无论是蓝/绿部署还是金丝雀发布,其出发点都是承认线上错误是不可避免的,因此MTTR是此类策略更关注的内容。而要保证MTBF,必须在上线前实现充分测试,其花费的成本也是十分可观。因此在实际中,MTTR和MTBF只能在权衡下保证,除非你有不计成本的投入(如重大的、允许失误率极低的项目)。而按照实际经验判断,MTTR可能是面向普通业务更为现实的选择。

4.跨功能测试

如今我们逐渐开始关注非功能测试,例如性能测试。但从词义上理解,把非功能测试看作是“非功能的”可能并不准确,因此这里采用“跨功能测试”一词。 跨功能测试在测试四方图中占有一席之地,因为其实际上十分重要,但在大多数项目中此类测试通常都启动得太晚,以至于出现了额外的超额工作。要知道,跨功能测试从重要性、架构复杂度和维护需求上几乎和所谓的功能测试并无差别,只是更容易在初期为人所忽视而已。

例如性能测试,在End-to-End测试阶段,这种测试更类似负载测试,而在单元测试阶段,则属于Benchmark测试。因此结合功能测试实现性能测试是一个有效途径。但应注意性能测试对环境的真实程度要求更高,可能会产生更多额外成本。但至少在当下,应尽量开始Benchmark及相关的尝试。

5.小结

总结上述这些与测试的有关经验,我们可以列出以下几点主要内容:

  1. 通过细分测试,优化快速反馈流程,减少项目风险。

  2. 通过消费者驱动的测试避免微服务架构下的UI测试。

  3. 采用消费者驱动契约建立微服务团队间的沟通渠道。

  4. 理解MTTR和MTBF的区别,以及实际运维中的权衡。

Comments