怎样才能提高研发效率?是依赖于各自独立的本地开发测试环境,还是依赖完整的端到端测试?Lyft 的这一系列文章介绍了其开发环境的历史和发展,帮助我们思考如何打造一套适合大规模微服务的高效研发环境。本系列共 4 篇文章,这是第 4 篇。原文:Scaling productivity on microservices at Lyft (Part 4): Gating Deploys with Automated Acceptance Tests[1]
本文是本系列文章的第四篇,也是最后一篇,主要讲述我们在 Lyft 面对越来越多的开发人员和服务时,如何扩展开发实践。
- 第一部分:开发和测试环境的历史
- 第二部分:优化快速本地开发
- 第三部分:利用覆盖机制在预发环境中扩展服务网格
- 第四部分:基于自动验收测试的部署门禁(本文)
在之前的文章中,我们描述了如何利用上下文传播从而允许多个工程师在共享的预发环境中进行端到端测试。现在我们看看另一部分——自动端到端测试,我们将介绍如何构建一个可伸缩解决方案,让工程师在部署到生产环境之前更有信心。
重新思考端到端测试
本系列的第1部分介绍了在 CI 中运行集成测试时遇到的许多挑战。服务和工程师数量的爆炸式增长导致运行测试的远程开发环境(Onebox)难以扩展,运行测试需要耗费大量时间。每个服务的集成测试也变得非常笨拙,差不多需要花费一个多小时才能完成,并且信噪比极低。工程师们不信任失败的测试,经常将其忽略,否则就会浪费更多的调试时间,而这会让事情更糟糕。
在覆盖 900 多个服务的数千个集成测试中,有一小组真正有价值的端到端场景,我们认为维护这些场景至关重要。比如用户可以登录,请求乘车,支付车费。这些场景中的问题在内部称为 SEV0(严重程度最高的事件)。这些问题将使乘客无法到达需要去的地方,或者使司机无法获得收入,因此必须不惜一切代价解决问题。
验收测试
当我们着眼于想要维护好的具有最高价值的端到端测试时,即使粗略看一下,也会发现它们看起来很像验收测试。这些测试在不需要了解内部实现细节的情况下,描述了用户如何与 Lyft 平台进行的交互。
考虑到这一点,我们决定从分布式模型(每个服务定义自己的集成测试集)转移到小型集中式验收测试集。这样做有两个好处。在技术上,将场景放在一起可以帮助我们消除重复,并在相关服务之间共享测试代码。在组织上,由一个单一的所有者负责协调这些测试的整体健康状况(这些测试仍然由不同的人编写和修改),并设计更好的隔离,以避免失控。
另一个关键决策点是何时运行这些测试。我们希望改变将端到端测试作为“内部”开发循环的一部分运行(在第2部分中描述了)的习惯,之前开发人员习惯于在开发过程中多次执行端到端测试,以取代单元测试或调用特定服务端点等策略。相反,我们想让 CI 快速运行,并鼓励人们更习惯于将端到端测试推迟到过程的后期。由于这些原因,我们选择在部署到预发环境后运行验收测试,作为生产环境部署的门禁策略之一。
构建框架
引擎
首先,需要一个引擎来提供简单的界面,以便像真正的用户一样使用 Lyft 的 API。幸运的是,我们已经在预发和生产环境中构建了类似的东西用于生成流量(参见第1部分中的预发环境)。这个库由几个关键概念组成:
- Actions(动作): 与 Lyft API 交互,例如,RequestRide Action 会调用 Lyft API,提供所需的出发地和目的地,开始寻找司机。
- Behaviors(行为): 大脑会根据一定的概率决定下一步该做什么,例如,假设刚刚请求乘车,下一步应该取消还是继续等待司机?
- Clients(客户端): 代表与平台交互的设备,通常是运行 Lyft 应用程序的手机,用于存储状态和协调 actions/behaviors。
这三个简单理念的结合是我们过去 5 年在预发和生产环境进行自动化测试的策略的基础,为我们提供了很好的服务。然而,要在验收测试中重用这些策略仍然需要考虑一个重要的差别——行为的概率性质(probabilistic nature)。在构建带有负载测试的行为时,考虑到随机性非常有助于消除意外 bug,因此我们将其设计为类似于模糊测试(fuzzer)[2]的东西,而这并不适合于具有确定性的对特定流的验收测试。
因此我们更新了库,允许客户端按照一系列步骤操作,作为行为的替代方案,从而弥补了这一差距。步骤可以是以下任何一个:
- Actions(动作): 如上所述,只是执行一个 API 调用。
- Conditions(条件): 阻塞下一步的执行,直到某个表达式为真,并有可选的超时时间,例如,司机可能会等到到达起点时,通过 PickedUp 动作通知 Lyft 已经接到了乘客。
- Assertions(断言): 确保客户端状态看起来与预期相符,例如,我们希望确保在请求乘车之前完成报价。
定义测试
在 actions、conditions 和 assertions 构建块就位之后,接下来需要决定在新的集中式主系统中定义测试的格式。以前的集成测试是在代码中进行的,但是我们决定切换到使用自定义配置语法定义验收测试。尽管有利有弊,但我们发现以这种方式定义测试能够提供一种强制功能,以保持测试的简单性和一致性,从而让更多人能够读/写测试,以及更好的维护测试。配置中暴露有限的接口,将大多数逻辑实现到前面提到的库中,从而可以更好的与其他验收测试或负载测试运行程序共享。
把所有这些放在一起,看看下面的测试场景示例:
# test_scenarios/standard_ride.yaml description: A standard Lyft ride between 1 driver and 1 passenger clients: - role: passenger steps: - type: Action action: Login - type: Action action: SetDestination - type: Assertion assertions: - ["price_quote", "between", 10, 20] - type: Action action: RequestRide - type: Condition conditions: - ["ride_status", "equals", "completed"] - type: Action action: TipDriver - role: driver steps: - type: Action action: Login - type: Action action: EnterDriverMode - type: Condition conditions: - ["ride_request", "exists", true] - type: Action action: AcceptRideRequest - type: Action action: PickUpPassenger - type: Condition conditions: - ["location", "equals", "destination"] - type: Action action: DropOffPassenger
值得注意的是,如果从零开始的话,那么基于现有的测试框架(如 Cucumber/Gherkin[3])可能会更好。而在我们的例子中,扩展现有的流量生成工具比尝试使用这些技术要容易得多。
部署门禁
我们将端到端测试从 PR 合并之前调整到合并后部署之前,很大程度上提高了开发人员的生产力。虽然一个典型的 PR 可能平均会包含 4 个提交(每个提交都会运行测试套件),但通常一次只会部署一到两个 PR,因此开发人员由于测试的不稳定而被阻塞的频率几乎减少了 10 倍。此外我们预期,如果 PR 没有了端到端测试提供的虚假的安全感,开发人员就会把更多资源投入到单元测试中,并且会建立 feature flag 等更安全的发布策略(我们在第3部分中讨论过现在可以基于每个请求进行配置覆盖)。
为了实现这一点,我们扩展了内部部署系统的门禁(deploy gate)概念。部署阶段可以被一个或多个门禁所阻塞,门禁表示允许部署进入下一个阶段之前必须满足的条件。一个典型的门禁例子就是我们在每个预发部署中都会包含的 bake time,这个门禁确保部署的系统运行了特定长度的时间,以便有任何问题的时候,可以给持续的模拟流量一个触发告警的机会。
每个验收测试都会将门禁添加为被测服务的依赖项,一旦预发环境部署完成,相应的门禁就会启动测试运行,并报告成功或失败。为了不至于减缓开发人员的速度,验收测试的目标是在比默认 bake time(10 分钟)更短的时间内完成。
实践
测试什么?
可以说,转换到验收测试的最困难的部分之一是确定验收测试的构成规则,并在大量集成测试中应用这些规则。在筛选了数百个集成测试并与服务所有者讨论之后,我们确定了以下标准:
- 验收测试应该只代表关键业务流,应该从用户角度描述与 Lyft 平台的端到端交互。
- 我们显然不想测试所有场景,因此验收测试对业务必须是关键的。作为套件中最昂贵的测试,我们无法负担测试那些短时间中断不会对业务造成重大损害(即 SEV0)的边缘情况或业务流。
虽然站在测试金字塔[4]的角度来看这些标准似乎很明显,但仍然比预期更难应用。开发人员对于删除集成测试的后果感到不安,无论该测试是否被很好的理解或者是否曾经捕获过 bug。为了简化转换,我们与团队合作,根据上述标准分析每一个测试。大约 95%的测试要么是多余的,要么可以重写为带有 mock 的单元测试。剩下的几个测试在去除冗余后被组合成大约 40 个总的验收测试场景,这些场景将取代所有的集成测试。
结果
从我们用预发环境验收测试取代 CI 中的集成测试以来,已经过去了大约 6 个月。场景数量保持相对稳定,我们已经将覆盖范围扩大到运输和自行车 &踏板车产品,每周进行几千次测试。我们看到的主要好处是:
- 大多数 PR 都能在 10 分钟内通过单元测试并准备好合并(之前包含端到端测试时需要 30 分钟)。
- 从服务中删除了数千个集成测试,无需花费大量时间来维护和调试这些测试。
- 验收测试迭代起来更快、更可靠,只需要不到一分钟的时间就可以准备一个以预发为目标的本地环境(使用前面提到的本地开发工作流,而 Onebox 的初始设置时间为 1 小时)。
- 自从将端到端测试从 PR 中移除后,泄漏到生产阶段的 bug 数量并没有明显增加。
- 验收测试每周在问题泄漏到生产环境之前将其捕获。
- 我们还没有看到希望的那样,在单元测试方面有额外的投资。这需要进一步的调查来理解为什么,以及我们是否可以/应该做的更多来改变这一点。
将来的工作
到目前为止,我们对从这些变化中看到的生产力提升感到兴奋,但仍然展望未来的许多改进。
预发隔离
目前,在将更改部署到预发环境后立即运行测试,可能会在出现问题时干扰到其他预发环境用户。我们希望在本系列第三篇文章中讨论的预发覆盖工作的基础上,在将新版本的服务公开给其他用户之前对其运行验收测试。这将为部署增加额外的延迟,因此需要评估收益是否大于成本。
测试覆盖率
考虑到测试背后的主要目标是提高可靠性,我们希望做更多更直接的改进,而不仅仅是维护这些测试。我们知道,今天的测试存在差距,这些差距是由之前的集成测试构建的,并与服务所有者讨论了哪些业务流重要,需要被测试覆盖。为了缩小差距并提高可靠性,需要确保真正的 iOS 和 Android 客户端所做的所有最常用的 API 调用都能在这些测试中得到体现。一个想法是对流经我们系统的真实流量和模拟流量之间的增量进行更多的分析,也许可以通过对分布式跟踪工具的进一步投资实现。
测试场景健康度
最初,平台团队手工策划了每个验收测试,并密切关注其稳定性。随着我们继续扩展更多的业务线,希望每个操作(API 调用)具有更细粒度的可观察性,这样就可以自动将故障发送给合适的团队处理。这并不意味着分散所有权(我们认为为更广泛的测试和平台保留中央所有者非常重要),只是更快的提醒产品团队他们的服务出现了故障,并尽量减少手工工作。
总结
在本系列文章中,我们仔细分析了 Lyft 多年来是如何发展开发和测试方法,并追求不断提升开发人员的生产力。我们介绍了 Lyft 开发环境的历史(第1部分),转向本地优先的第一次研发环境转型(第2部分),在预发环境隔离测试服务与 envoy 覆盖(第3部分),用部署期间用一组验收测试取代 PR 触发的较重的集成测试(本文)。
尽管这种方法可能没办法适用于所有环境,但在缩短开发人员的反馈循环方面取得了很大的成功,并极大简化了支持测试环境的基础设施,从而帮助开发人员持续输出代码。
References:
[1] Scaling productivity on microservices at Lyft (Part 4): Gating Deploys with Automated Acceptance Tests: https://eng.lyft.com/scaling-productivity-on-microservices-at-lyft-part-4-gating-deploys-with-automated-acceptance-4417e0ebc274
[2] Fuzzing: https://en.wikipedia.org/wiki/Fuzzing
[3] Gherkin: https://cucumber.io/docs/gherkin/
[4] The Pratical Test Pyramid: https://martinfowler.com/articles/practical-test-pyramid.html