作者 | Naresh Jain
译者 | 明知山
策划 | 丁晓昀
独立开发和部署单个微服务的能力是成功采用微服务策略最关键的指标。然而,大多数团队在部署微服务之前必须经历大量的集成测试。这是因为集成测试已经成为识别微服务之间兼容性问题的必要条件,因为单元和组件或 API 测试没有覆盖微服务之间的交互。
首先,集成测试是一种发现兼容性问题的后期反馈机制。修复这些问题的成本随着发现时间的推移而成倍增加(如上图底部的热图所示)。
此外,这可能会导致客户端和服务端团队做大量的返工工作,严重影响特性交付的可预测性,因为团队不得不兼顾常规的特性开发和集成错误修复。
集成环境可能非常脆弱。由于两个组件或服务之间的兼容性问题,即使是单个中断的交互也会导致整个环境受到损害,这意味着即使是其他不相关的功能和微服务也无法测试。
这给生产交付造成了阻碍,即使是对关键问题的修复,而且会让整个交付过程陷入停顿,我们称之为“集成地狱”。
1 集成测试——了解这头野兽
在终结集成测试之前,我们先来了解它到底是什么。这个词经常被用在不恰当的地方。
测试应用程序不仅仅是测试每个函数、类或组件的逻辑。应用程序的功能是这些单独的逻辑片段与其对应部分交互产生的结果。如果两个组件之间的服务边界或 API 没有理清楚,就会导致通常所说的集成问题。例如,如果函数 A 只使用一个参数调用函数 B,而函数 B 需要两个必填的参数,那么这两个函数之间就存在集成或兼容性问题。这种快速的反馈有助于我们尽早纠正并立即解决问题。
然而,当我们在微服务级别(服务边界位于 HTTP、消息传递或事件级别)识别兼容性问题时,单元和组件或 API 测试都无法立即识别出任何偏离或违反服务边界的行为。微服务必须与所有实际对应的服务一起测试,才能验证是否存在中断的交互。这些被广泛地(在某种程度上错误地)归类为集成测试。
集成测试这个词被用来描述很多类型的检查:
    1. 两个或多个组件之间的兼容性;
    2. 工作流测试——涉及交互编排的整个功能;
    3. 与其他依赖项(如存储、消息传递基础设施等)的交互;
    4. 还有更多,生产基础设施的端到端测试除外。
需要明确说明的是,当我们说终结“集成测试”时,我们说的是消除对“集成测试”的依赖,不要将其作为识别微服务之间兼容性问题的唯一方法。但其他东西,例如工作流测试,可能仍然是必要的。
2 确定拐点——知道从哪里下手
当所有代码都属于一个单体,方法签名可能就可以作为服务边界的 API 规范。我们可以通过编译时检查等机制强制执行方法签名检查,从而为开发人员提供早期反馈。
然而,当一个服务的组件被拆分为多个微服务,服务边界变为接口(如 HTTP REST API)时,这种早期的反馈就不会有了。在之前作为方法签名进行文档化的 API 规范现在需要被显式地文档化,描述清楚正确的调用方法。如果 API 文档不是机器可解析的,还可能会导致团队之间的沟通混乱。
如果没有良好文档化的服务边界:
    1. 只能使用近似模拟的服务端来构建客户端,而手动模拟和存根技术通常会导致存根过期的问题,即存根无法真正表示服务端。
    2. 对于服务端来说,无法模拟客户端。
这意味着我们必须采用缓慢的串行化开发风格,即在开始开发另一个组件之前必须等待其中一个组件构建完成。如果需要快速发布特性,这就不是一种高效的方法。
转向微服务后,我们失去了两个关键的能力:
    1. 清楚地表示两个组件之间服务边界的 API 规范;
    2. 强制执行描述服务边界的 API 规范。
我们需要另一种方法来弥补这两方面的缺失。
3 API 规范
如果想要恢复清晰且按照机器可解析的方式来表示 API 签名的能力,采用 API 规范标准(如 OpenAPI 或 AsyncAPI)就变得至关重要。虽然这增加了开发人员创建和维护这些规范的工作量,但利大于弊。
尽管如此,API 规范,顾名思义,也只是有助于描述 API 签名。在开发过程中,为了获得早期的反馈,又该如何强制执行它们呢?这一部分仍然是缺失的。
4 代码 / 文档生成——无效且不可持续
我们可以认为,我们可以通过代码生成技术来生成和维护 API 规范。从表面上看,如果代码是基于规范生成的,就不会偏离规范。
然而,这里存在一些难点:
    1. 正在进行中的开发——大多数代码生成工具 / 技术为服务器端和客户端代码生成脚手架,并要求我们在这个脚手架 / 模板中填写业务逻辑。问题是,当规范发生变化时,我们通常需要重新生成脚手架,从旧版本的代码中提取业务逻辑,并再次粘贴到新的脚手架中,这增加了犯人为错误的可能性。
    2. 数据类型不匹配——代码生成工具 / 技术必须支持每一种编程语言。在多语言环境中,生成的脚手架在不同编程语言之间的数据类型(或其他东西)可能不一致。如果我们为一种编程语言生成文档(基于服务端代码生成 API 规范),然后利用生成的规范进一步为客户端代码生成脚手架,这将进一步加剧这种情况的恶化。
数据类型不匹配——代码生成工具 / 技术必须支持每一种编程语言。在多语言环境中,生成的脚手架在不同编程语言之间的数据类型(或其他东西)可能不一致。如果我们为一种编程语言生成文档(基于服务端代码生成 API 规范),然后利用生成的规范进一步为客户端代码生成脚手架,这将进一步加剧这种情况的恶化。
总的来说,代码生成和文档生成只能满足有限的场景。虽然它们最初可能通过生成代码为团队提供快捷的构建应用程序的方法,但这种技术的持续成本会让团队不堪重负。
因此,我们需要另一种方法来执行 API 规范。
5 契约驱动开发——API 规范作为可执行契约
方法签名可以由编译器强制执行,在开发人员偏离方法签名时向他们提供早期反馈。那么 API 也能实现类似的效果吗?
契约测试就是实现这种效果的一种尝试。Pact.io 的文档 中写道:
契约测试是一种测试集成点的技术,它会单独检查每个应用程序,确保它们发送或接收的消息符合记录在“契约”中的内容。
不过需要注意的是,契约测试本身也包含了几种方式,例如客户端驱动的契约测试(Pact.io)、服务端驱动的契约测试(生产者契约测试方法中的 Spring 云契约)、双向契约测试(Pactflow.io)等等。在大多数这些测试方法中,API 契约是独立于 API 规范的文档。例如,在 Pact.io 中,JSON 就是 API 契约。Spring 云契约也有用于定义契约的 DSL。与其维护两个不同的工件(可能会导致不同步),不如利用 API 规范本身作为 API 契约,在开发人员偏离 API 规范导致客户端出现问题时为他们提供早期反馈,这样会不会更好?
Specmatic 就是这样做的。Specmatic 是一个开源的基于契约驱动开发的工具。它将客户端和服务端之间的交互划分为独立可验证的单元。考虑下面两个微服务之间的交互,目前只在更大级别的测试环境中进行验证。
ServiceA <-> ServiceB
CDD 可以将这种交互分解成连续的组成部分:
ServiceA <-> Contract as Stub {API spec of ServiceB} Contract as Test {API spec of ServiceB} <-> ServiceB
现在我们来仔细研究一下。
    1. 左边:ServiceA => Contract as Stub 我们为客户端(ServiceA)模拟服务端(ServiceB),这样客户端应用程序开发就可以独立于服务端进行。由于 Contract as Stub(智能 Mock)是基于双方约定的 API 规范,因此能够真正作为服务端(ServiceB)的 Mock,它会在客户端(ServiceA)调用 API 并偏离 API 规范时给出反馈 / 抛出错误。
    2. 右边:Contract as Test => ServiceB 为服务端(ServiceB)模拟客户端(ServiceA),并验证响应是否符合双方约定的 API 规范。Contract as Test 将在服务端(ServiceB)应用程序开发人员偏离规范时立即向他们提供反馈。
既然我们可以在组件级别让客户端(ServiceA)和服务端(ServiceB)应用程序遵守 API 规范,同时又可以独立构建,那么就没有必要将它们部署在一起来测试它们的交互。这样我们就不需要再依赖集成测试来识别兼容性问题。
Specmatic 就是这样利用 API 规范作为可执行契约。
6 契约即代码
这里的关键是 API 规范本身,它可以让 API 提供者和使用者分离并独立地驱动各自组件的开发和部署,同时保持所有组件的一致性。
为了成功地进行契约驱动开发,我们需要采用 API 优先的方法,即 API 提供者和使用者需要先协作设计和记录 API 规范。这意味着他们需要使用现代的可视化编辑器之一,如 Swagger、Postman、Stoplight 等来编写 API 规范,在开始独立构建各自的部分之前专注于 API 设计,并确保所有利益相关者保持同步。
习惯于基于代码生成 API 规范的团队可能会对这种先编写 API 规范的反向流程感到不适应。CDD 需要类似测试驱动开发的心态转变。在进行测试驱动开发时,我们需要通过先手写测试来指导 / 驱动代码设计。类似地,在 CDD 中,我们需要先手工编写 API 规范,然后使用 Specmatic 等工具将它们转换为可执行的契约测试。
我发现,对于基于代码生成 API 规范的方法来说,API 设计处于次要地位,变得更像是事后的想法,或者是偏向于客户端或服务端。此外,由于发布时间的压力,在采用 API 规范优先的方式时,我们能够并行独立开发客户端和服务端组件,而基于代码生成 API 规范这种方式是不可能做到这一点的(客户端必须等待服务端代码完成并生成了规范)。
在就公共 API 规范达成了共识之后,让这些 API 规范有一个单一的真实来源就变得非常重要。如果这些规范出现了多个副本,会导致客户端和服务端团队在实现方面出现分歧。
CDD 建立在三个基础支柱之上。“契约即存根(Contract as Stub)”和“契约即测试(Contract as Test)”让客户端和服务端团队保持一致,但将一切联系在一起的粘合剂是第三个支柱——“中央契约存储库”。
API 规范是机器可解析的代码,所以还有什么地方比版本控制系统更适合存储它们的呢?将它们存储在版本控制系统(如 Git)中,我们就可以通过添加 Pull/Merge 请求过程来为它们的构建过程增加一些严格性。理想情况下,Pull/Merge 请求应该包括以下步骤:
    1. 语法检查,确保一致性;
    2. 向后兼容性 检查,确定是否有任何重大变更;
    3. 最后的评审和合并。
强烈建议将规范存储在同一个中心位置,这适用于大多数情况(甚至是大型企业)。除非绝对有必要,否则不建议跨多个存储库存储规范。
等到规范被存储到了中央存储库中,它们就可以被:
    1. 客户端和服务端团队使用,分别进行独立的开发;
    2. 发布到 API 网关。
7 集成测试的终结
我们已经消除了对通过集成测试来识别应用程序兼容性问题的需求,那么系统测试和工作流测试呢?
CDD 为更大级别的测试环境铺平了道路,因为所有兼容性问题都在开发的更早阶段(在本地和 CI 等环境中)被识别出来,在这些环境中修复问题的成本要低得多。我们可以通过系统测试和工作流测试在稳定的更大级别的环境中验证复杂的编排问题。另外,由于我们已经不需要通过集成测试来识别兼容性问题,在更大级别的环境中测试套件的总体运行时间也缩短了。
原文链接:
https://www.infoq.com/articles/contract-driven-development/
声明:本文为 InfoQ 翻译,未经许可禁止转载。
点击底部阅读原文访问 InfoQ 官网,获取更多精彩内容!
今日好文推荐
继续阅读
阅读原文