近年来,随着微服务架构的广泛采用,契约测试(Contract Testing)越来越受欢迎。在这篇文章中,我们将分享我们在 eBay 的契约测试的经验。

在微服务架构中,服务通常通过远程过程调用或异步消息进行整合。测试微服务集成的传统方式是通过端到端的集成测试。不过,由于外部的依赖性,集成环境可能是不稳定的,这会让端到端测试变得很脆弱,效率也很低。这在现实世界的场景中是很常见的。

我们 eBay 的 Notification Platform 团队面临的另一个挑战是,我们的 API 被许多领域的团队所使用。在发展我们的服务 API 的同时,保持与所有消费者的兼容性是我们的一个基本原则。在这篇文章中,我们将介绍在 eBay,我们是如何通过采用契约测试来解决上述挑战的,以及我们为将消费者驱动的契约测试工作流程深度整合到 eBay 的开发生态系统而构建的工具。

编译整理|TesterHome 社区
作者|Xiaoye Wang, Simon Wang, Daphne Huang, David Van Couvering and Qingyuan Liu

01 寻求解决方案

我们的主要目标是探索一种可靠的方式来演化 API,并具有向后兼容性。由于我们的 API 是基于 OpenAPI 规范的,我们评估了通过 OpenAPI 模式进化与语义版本确保 API 兼容性的可能性。这种方法完全由服务提供者管理。例如,即使数据属性的重命名不会破坏消费者的数据消费(例如反序列化),我们仍然需要保守地进行改变,因为我们不知道客户是否使用这个特定的数据属性。

OpenAPI 规范本身并没有反映出 API 消费者对模式的依赖程度。有时服务提供者不得不选择数据属性冗余来确保安全。这个解决方案也没有解决端到端测试问题的脆弱性。

我们需要一种方法来使消费者的依赖关系正规化,就像提供者的 API 规范一样。我们首先研究了 BDD(行为驱动开发)的方法,即用一种特定领域的语言(在我们的例子中是 Gherkin)将 API 消费者的需求正式化为行为规范。基于由 API 消费者和提供者共同定义的 BDD 规范,我们可以实现提供者一方的测试用例,以涵盖我们单元测试中 API 行为的验证。这似乎填补了消费者和提供者之间的信息空白。

但是,如果消费者在没有更新行为规范的情况下改变需求怎么办?我们如何保证消费者的行为始终与规范一致?使用 BDD 来确保 API 的向后兼容性,在很大程度上仍然依赖于执行该过程的技术人员。这种方法只在那些理想的情况下起作用,即一切完全按计划进行(即 API 供应商的功能测试用例可以始终覆盖所有消费者行为)。

鉴于 BDD 方法的缺陷,我们研究了另一种方法来解决这个问题:契约测试。在这种情况下,契约是消费者和提供者之间关于 API 行为的最小协议。根据不同的实现方式,消费者可以在他们的测试用例中针对模拟(或存根)明确设置 API 预期值,这些预期值随后可以被翻译成与编程语言无关的中间文件,描述契约的交互。然而,API 提供者需要在提供者的验证测试中满足所有消费者契约。通过利用契约测试,我们可以建立一个系统的工作流程,使这个过程可执行,并及早发现兼容性问题。

契约测试提倡基于预定义的契约而不是真正的端到端交互,对集成点进行孤立的单元测试的想法,使其相对快速和稳定。

02 契约测试框架

在 Notification Platform 团队中,我们评估了两个流行的契约测试框架:Spring Cloud Contract 和 Pact。

契约可以是提供者驱动的,也可以是消费者驱动的。在提供者驱动的方法中,契约是由 API 提供者定义的;API 消费者端单元测试依赖于从契约中导出的 API 存根。这在多组件系统的隔离测试中有时很有用,因为应用开发者同时控制着提供者和消费者。组件之间的交互可以用存根来模拟,而且双方之间没有通信间隙。

消费者驱动的契约从消费者的角度记录每一次交互。不同的消费者可能有不同的要求,而提供者有义务履行所有的契约。与生产者驱动的契约相比,这是一种更广泛接受的服务测试范式,可以在保持向后兼容性的同时发展服务。

消费者驱动的工作流程

Spring Cloud Contract 最初是作为一个提供者驱动的框架建立的,但是可以通过预定义的工作流程实现消费者驱动的契约测试:

图 1:Spring Cloud Contract 中消费者驱动的契约测试工作流程

1.服务消费者从共享契约库中克隆,然后在特性分支中添加新的契约或修改现有的契约。

2.服务消费者从定义的契约中生成存根,将存根安装到本地文件系统中,并使用本地生成的存根编写测试案例。

3.在测试用例通过契约验证后,服务消费者可以创建一个契约的拉动请求,从特性分支到主分支。

4.服务提供者实现 API 以满足消费者定义的契约,编写验证测试以确保实现满足契约,然后合并拉动请求。

5.然后,服务提供者可以将生成的存根的最终版本发布到远程存根库中。

6.消费者将他们的存根依赖从特性分支更新到发布的版本。

如上图所示,我们看到该工作流程需要多个步骤,并涉及双方之间的来回沟通。最重要的是,契约是人工管理和维护的。对于一个有多个消费者的 API 来说,所有的团队都需要在一个共享的契约库上合作,并遵循预定义的文件夹结构来组织契约文件。这在沟通和维护方面增加了额外的复杂性和成本。

Pact 的工作流程要简单得多:

1.服务消费者使用 Pact 提供的 mock DSL 来定义服务预期。

2.Pact 契约定义 DSL 也会生成一个可以在单元测试中使用的工作 mock。在消费者通过单元测试后,mock 文件被上传到 Pact Broker。

3.Pact Broker 会针对服务提供者重复所有存储的契约,并将响应与契约进行比较。

图 2:Pact 中消费者驱动的契约测试工作流程

Pact 引入了一个叫做 Pact Broker 的契约管理系统(商业版本叫做 Pactflow)。Pact Broker 解决了 Spring Cloud Contract 中手动管理的共享契约库的许多缺点,并使契约测试更适用于跨团队协作的场景。

在 eBay 的 Notification Platform 团队中,我们首先在 Spring Cloud Contract 中实现了所有的测试案例。然而,在了解了 Pact 并评估了这两个框架后,我们发现 Pact 中的工作流更适合我们的协作场景,于是我们用 Pact 重新实现了所有的测试案例。

HTTP 集成

在 Spring Cloud Contract 中,用户通常用 Groovy(或 Yaml、Kotlin 或 Java)DSL 定义 HTTP 服务契约。Groovy 契约 DSL 非常具有表现力和灵活性,但 IDE 的工具支持相对较弱(如契约定义 API 的自动完成);开发人员需要参考文档来处理稍微复杂的用例。Spring Cloud Contract 主要针对 JVM 技术栈,与 Spring 生态系统结合得更紧密。虽然它声称通过 Docker 容器支持其他技术栈,但用户体验会有很大不同。

另一方面,Pact 为主要的编程语言提供本地 API 绑定。API 消费者使用 Pact 契约 DSL 来定义单元测试中的 API 预期。Pact DSL 将 API 行为预期转化为本地可用的模拟服务,并可用于测试案例。通过测试用例后,记录的契约文件将被转移到 Pact Broker。提供商将重放所有的契约,以确保没有任何一个契约被破坏。Pact 的本地 API 绑定导致了更好的 IDE 工具支持,但学习起来有一定的难度。

Pact 还提供了一个状态管理 API,用于控制每个交互的前、后条件。与 Spring Cloud Contract 不同的是,提供者验证测试不是自动生成的,但 Pact 提供了库来自动完成大部分工作。

根据我们的经验,Spring Cloud Contract 就像一个辅助库,使你能够在 Spring 中实现契约测试。它与 Spring 有更紧密的集成,并且基于 Java 生态系统中流行的库。然而,Pact 是一个全功能的契约测试解决方案,它有自己的一套库和契约管理工具和规范。

消息集成

Spring Cloud Contract 支持广泛的集成:Apache Camel、Spring Integration、Spring Cloud Stream、Spring AMQP、Spring JMS 和 Spring Kafka 等等。消息传递支持在很大程度上是基于 Spring 的消息传递抽象的。在消费者方面,生成的存根可以由方法或消息触发,或通过 StubTrigger 接口手动触发。如果用户在契约中指定由消息触发,消费者的测试用例实际上可以由真正的消息触发,这更接近于模拟真实的集成。

然而,Pact 采取了不同的方法:它抽象了消息媒介,专注于用模拟消息对消息处理逻辑进行单元测试。实际处理程序的预期参数数据类型和模拟消息的数据类型之间可能存在差距。

总结一下消息契约的测试支持,Spring Cloud Contract 为 Spring 消息抽象提供了无缝集成,也可以模拟真实的消息交互,而 Pact 则很灵活,但需要一点手工集成的操作。

03 eBay 的契约测试

契约初始化程序项目(The Pact Initializer Project)

一个正确实施的契约测试工作流程需要在整个应用开发过程的各个阶段与 Pact Broker 进行互动。Pact 初始化项目由 eBay 的应用平台团队建立,是一套引导性服务,用于将 Pact 契约测试框架集成到 eBay 的开发生态系统中。

图 3:契约初始化程序的架构概述

1.Pact Initializer 门户网站为契约测试环境设置收集配置元数据。在开发人员输入所需的配置后,后端初始化服务被触发。

2.后台初始化服务解析配置元数据,生成相应的初始化任务并触发事件。

3.任务执行器也会将执行结果发送到分析后端,以便监测和进一步分析。

统一的提供者验证服务

当消费者改变契约时,提供商只需要验证与新变化的兼容性。Pact 提供了一个 webhook,将每个消费者与它的提供者验证 CI/CD 作业绑定。然而,对于每个消费者来说,他们需要重复地为 Pact Broker 设置服务账户和验证配置,以触发验证过程。我们可以使用代理服务来管理每个验证的共同步骤,并将消费者从管理这些配置中解放出来。

图 4:一个统一的验证服务,用于触发提供者一方的验证过程

1.服务消费者使用 Pact Initializer Portal 来初始化环境。

2.验证服务存储配置元数据。

3.服务消费者向 Pact Broker 发布契约变更。

4.Pact Broker 检测变化并将变化转发给验证服务。

5.验证服务从数据存储中读取目标验证作业信息并触发 CI 作业。

04 契约测试最佳实践

选择合适的框架

可以说,这两个框架都可以在实现契约测试工作流程方面有不错的表现,选择最佳框架有几个考虑因素:

● 应用程序栈。非 Java 应用可以直接使用 Pact,它提供了原生的 API 绑定,而且用户体验更好。如果你的应用是基于 Spring 的微服务,Spring Cloud Contract 可能是有用的,并且与 Spring 的生态系统有更紧密的整合。

● 协作的规模。如果你与多个团队或项目合作,Pact broker 可以把你从契约管理的麻烦中解救出来。

● 消费者驱动或供应商驱动。Spring Cloud Contract 允许定义一个提供者驱动的工作流程,有时这很有意义:要么同时拥有消费者和提供者的应用程序,要么提供者可以单独定义 API 行为(如公共 API)。

稳健性原则

对你发送的东西要保守,对你接受的东西要宽松。在契约测试的背景下,这意味着消费者代码应该只向提供者发送他们真正依赖的请求参数,而来自提供者响应的额外数据属性不应该破坏消费者。

专注于通信契约而不是功能

契约测试位于单元测试和服务集成测试之间。它并不试图取代功能测试,它取代了对真实提供者的响应的依赖,以验证通信数据格式。契约测试应该只关注集成点,而不是业务逻辑(即供应商的响应是否符合业务需求)。这些领域应该由供应商的功能测试来覆盖。

BDD 是对契约测试的良好补充

如上一段所述,契约测试不应测试供应商的特性或功能。BDD 是一种很好的方法,可以正式确定消费者的需求,并强制执行提供者的功能测试覆盖。因此,它是契约测试的一个很好的补充。

避免不必要的严格的断言

消费者应该松散地验证数据属性,而不是严格地验证,以增强测试案例的稳健性。例如,如果你不依赖数据属性的长度,断言该属性不是空的比断言该属性有固定的长度更可维护。通常,消费者依靠的是响应的结构,而不是值的内容。

总结

在这篇文章中,我们比较了不同的契约测试框架,并分享了我们在 eBay 的采用经验。我们相信契约测试对于测试微服务架构中的集成点是很有价值的,并且有效推动业务发展。


↙↙↙阅读原文可查看相关链接,并与作者交流