作者:薛金库|QE_LAB
说到 Contract Testing,我们或多或少的听说过,不过相对而言,我觉得它没有 API 测试、E2E Testing 那么普遍,那么什么是 Contract Testing? 它的应用场景是什么?它的一个测试流程是怎样的?结合我们在项目上的实践,整理下自己对契约测试的理解,也期待与大家一块儿探讨、交流。
Contract Testing,即契约测试,主要通过隔离检查每个应用/服务与其他应用/服务间的交互(发送消息或接受消息)是否符合契约中的描述,从而验证应用/服务间的集成。在这里契约Contract就显得至关重要。
关于契约,在现实生活中,我们一切商业活动都是围绕契约展开的,从我们个人与公司签订的 Employment Contract 劳动合同, 到国家与世界组织签订的 WTO Agreement, 这些契约(合同/合约/协议)中描述了参与双方的权利与责任(义务)。
而在针对 Http 的 Contract Testing 中,接口协议文档也可以看作 是一种契约,它定义了客户端与服务端的交互(请求 URL,请求方法,数据格式,状态码等),契约中的参与双方分别是客户端(请求方)跟服务端(服务提供方),通过契约可以帮助我们实现客户端与服务端的解耦。
随着微服务的兴起, 系统也由传统的的单体架构演变成了多个分布式微服务,系统的维护职责也随着划分到了不同的团队,不同的团队负责开发维护不同的服务。
毋庸置疑,微服务带来了很多好处,如:单个服务可以快速独立部署,服务的高可扩,多个服务可以并行开发等,但同时也引入了一些问题,我们如何确保这些微服务的集成没有问题?通常我们会为其添加 E2E testing 来进行服务间集成测试,另外我们可以通过更加轻量化的契约测试来确保服务间的集成。
再想象一下这些场景:
我们可以尝试通过契约测试来解决这些问题:
契约测试能够基于契约进行快速反馈,在客户端跟服务端集成测试之前就能够发现问题。
契约测试流程促使客户端来主导 API 的接口协议制定,服务端只要确保 API 能够通过契约验证,就能保证提供的 API 满足客户端的需求。
通过契约所维护的 Consumer 与 Provider 的关系图,可以明确知道服务有哪些 Consumer,breaking change 影响了哪些调用方。
基于 Contract,Consumer 端跟 Provider 分别进行独立测试,测试执行速度更快,更稳定。
契约测试中的参与方主要有:Consumer,Provider,Contract, 契约测试的思想就是将原本的 Consumer 与 Provider 间同步的集成测试,通过 Contract 进行解耦,变成 Consumer 与 Provider 端两个各自独立的、异步的单元测试。
目前我们所说的 Contract testing 基本上都指的是 Consumer-driven Contract Testing,CDCT 即由 Consumer 驱动的契约测试。Pact和Spring Cloud Contracts是目前最常用的契约测试框架, Pact 实现就采用 Consumer-driven Contract Testing。
Consumer Driven Contracts,契约测试中通常由客户方(需求方)来驱动生成契约文件,这样做是比较合理的,契约文件中定义了 Consumer 端所期待与 Provider 的交互,此处跟 TDD 的思想很像,以需求为导向,以实现需求为最终目标,以终为始,由客户方(需求方)定义、生成了契约文件后,Provider 端只需要去实现符合契约定义的 API 即可。
Provider 端验证契约,每一个定义在契约里的请求都会在 Provider 端进行重放,主要通过一个 Mock Client 来模拟 Consumer 来发送请求从而获取 Response,再与契约文件中期望的 Response 做对比,从而验证契约是否正确。
下图展示了契约测试中的关键步骤,不再赘述,另外可以参阅官方给出的动画演示以更好的理解其步骤:https://pactflow.io/how-pact-works/
图片来自:https://docs.pact.io/img/how-pact-works/summary.png
我们的项目是一个前后端分离的项目,前端 UI 使用 React,后端 API 服务使用 Scala,在项目中引入了契约测试前,大家有过一些考虑和争议:
契约测试是否有必要?
项目上已有 E2E Testing(只覆盖了个别重要的 feature),另外前后端的项目都是由我们组自己来维护,我们团队拥有整个项目的上下文,如果前端做了一些 change, 我们也会及时地去修改对应的后端 API,或者后端 API 做了调整, 我们也会相应地去修改前端 UI,以及 E2E Testing。还有必要添加契约测试来进行保证吗?
引入契约测试会影响开发进度?
引入契约测试后除了编写正常的 feature 代码外,当有 breaking change 时还得添加或者修改 Consumer 和 Provider 端契约测试的代码,另外在 pipeline 上集成了契约测试后,也会增加了 pipeline 的运行时间(CI/CD 的时间)。
接下来,我们看下项目中的具体实践:
在契约测试中我们会用到上面提到过的开源的契约测试的框架/工具—Pact,pact 已经成为契约测试事实上的标准了。
Pact 最开始通过 Ruby 编写,目前也提供了很多其他语言(Ruby/Java/.NET/JavaScript/Go/Scala/Groovy...)下的实现,在我们的项目中 Consumer 端我们使用 JavaScript 版的Pact JS,Provider 端我们使用了 Ruby 版的Pact Ruby 。
在开始之前先要安装 Pact 包,可以通过 npm 来安装:
npm install --save-dev @pact-foundation/pact@latest
在 Consumer 端, 我们主要会去描述针对一个个特定的 Provider 所期望的行为。
const provider = new Pact({
consumer: 'XXX UI',
provider: 'XXX Api',
MOCK_SERVER_PORT,
spec: 2,
cors: true,
pactfileWriteMode: 'merge'
})
在这里我们定义了一个 Provider,指定了 Consumer name,Provider name(这块儿定义的名称也是后面自动生成的契约文件中的相应的 Consumer 跟 Provider 的名字)端口号等。
2.启动一个 mock server
通过调用provider.setup()
方法来启动一个 mock server
3.定义 Consumer 与 Provider 间的交互内容
通过调用provider.addInteraction ()
方法来添加交互,来定义我们期待的 Provider 端的响应。在每个交互里的state标识了 Provider 当前所处的某种的状态(Provider states),不同状态下返回不同的响应,怎么理解呢?可以对应到我们写 AC 或者写测试时 BDD Style 的 Given When Then 中 Given 部分,即描述了前提条件,在该 state 下我们会期待 provider 端给我们什么样的返回。我们可以通过多次调用该方法来设定 Provider 在不同状态下(如happy path、sad path等)的响应内容。
4.编写测试(正常的单元测试)
5.验证与 Provider 的交互是否正确,符合预期
6.生成契约文件,关闭 mock server
代码示例:
import * as React from 'react'
import { render, waitForElement } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import config from '../../config'
import {Matchers, Pact} from '@pact-foundation/pact'
import { csrfToken, jwtMfaToken } from '../../pages/__data'
jest.mock('purs/Segment')
const MOCK_SERVER_PORT = 8085
config.apiHost = `http://localhost:${MOCK_SERVER_PORT}/api`
// (1) Create the Pact object to represent your provider
const provider = new Pact({
consumer: 'XXX UI',
provider: 'XXX Api',
MOCK_SERVER_PORT,
spec: 2,
cors: true,
pactfileWriteMode: 'merge'
})
// this is the response you expect from your Provider
const EXPECTED_BODY = Matchers.like([
{
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
active: true,
name: 'Unicorn'
},
{
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx2',
active: false,
name: 'InactiveCompany'
}
])
describe('page/Companies', () => {
beforeAll(() => {
return provider
// (2) Start the mock server
.setup().then(() =>
// (3) add interactions to the Mock Server
provider.addInteraction({
state: "api has a list of user's companies",
uponReceiving: 'a request for company list',
withRequest: {
method: 'GET',
path: Matchers.term({
generate: '/api/company',
matcher: '/api/company$'
}),
query: {
au: 'true',
online: 'true'
},
headers: {
'X-Tsec-Csrf': csrfToken,
Cookie: Matchers.term({
generate: `session=${jwtMfaToken}; tsec-csrf=${csrfToken}`,
matcher: 'session=.+'
})
}
},
willRespondWith: {
status: 200,
body: EXPECTED_BODY
}
})
)
})
// (4) write your test(s)
it('should display companies page with the data from api', () => {
const { getByText } = render(
<MemoryRouter>
<Companies />
</MemoryRouter>
)
return waitForElement(() => getByText('ActiveCompany')).then(() =>
// (5) validate the interactions you've registered and expected occurred
expect(() => provider.verify()).not.toThrow()
)
})
// (6) write the pact file for this consumer-provider pair,
// and shutdown the associated mock server.
afterAll(() => {
return provider.finalize()
})
})
查看在测试执行时输出的 log
跑完测试后你会在 pacts 目录下看到生成的 json 格式的 pact 契约文件:xxx_ui-xxx_api.json,其中定义了 Consumer 与 Provider 间所有的交互的请求、响应,匹配规则等。
{
"consumer": {
"name": "XXX UI"
},
"provider": {
"name": "XXX Api"
},
"interactions": [
{
"description": "a request for company list",
"providerState": "api has a list of user's companies",
"request": {
"method": "GET",
"path": "/api/company",
"query": "au=true&online=true",
"headers": {
"X-Tsec-Csrf": "XXX",
"Cookie": "session=XXX"
},
"matchingRules": {
"$.path": {
"match": "regex",
"regex": "\\/api\\/company$"
},
"$.headers.Cookie": {
"match": "regex",
"regex": "session=.+"
}
}
},
"response": {
"status": 200,
"headers": {
},
"body": [
{
"cdfId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"active": true,
"name": "Unicorn"
},
{
"cdfId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"active": false,
"name": "InactiveButRegisteredUnicorn"
}
],
"matchingRules": {
"$.body": {
"match": "type"
}
}
}
}
],
"metadata": {
"pactSpecification": {
"version": "2.0.0"
}
}
}
关于 Pact JS 的具体的使用可以参考官方示例。
同样的,如果我们的 Consumer 端是一个 Java 微服务,其 Provider 是一个外部的 REST 服务,这时候我们可以使用 Pact 针对 Java 的实现版本,具体可参考Java Contract Test。
对 CI/CD 的修改:
我们使用pact_broker-client提供的命令 can-i-deploy 来查询最新版本的契约校验记录是否存在。
如果记录存在:
如果记录不存在:
在 Provider 端,我们会重放契约文件中定义的交互请求。在验证时,契约中的每个交互都会被拿来进行单独验证,每个交互都是独立、隔离的,彼此间互不干扰,不依赖上一个交互的执行结果。
指定契约文件路径(可以是远端的 Pack Broker 的 URL,或者本地文件路径),Provider 端每次做契约验证时,都会去跟最新的契约来进行验证。
在 Provider 端,针对契约中定义的每一个交互,我们需要根据交互中定义的 Provider State 来做些准备数据,Pact 测试框架会帮我们在执行验证每一个交互前,执行我们所定义的数据准备 task
在 CI/CD 上,我们给 Provider 端添加了 Pact 测试 step:
执行 Pact 校验过程中输出的 log:
Pact Broker 是一个工具可以帮我们实现在 Consumer 跟 Provider 间共享 pact 契约文件,以及共享契约测试的结果,同时它可以管理不同版本 pact 文件,以及可视化服务间的关系。
Pact Broker 也提供了docker 镜像,可以快速部署 Pact Broker。
契约发布
Consumer 端可以创建 publishPacts.js , 通过执行 node publishPacts.js 即可将 pacts 目录下的契约发布到 pact broker 上。
const pact = require('@pact-foundation/pact-node');
const path = require('path');
pact.publishPacts({
pactUrls: [path.join(process.cwd(), 'pacts')],
pactBroker: 'http://localhost:8080',
consumerVersion: '1.0.0'
});
Pact Broker 的主界面
点击查看契约
查看契约版本校验状态
查看服务间关系
注:在 local 环境自己做契约测试时可以不用搭建 pact broker,先运行 Consumer 端的测试,跑完测试后会生成契约,将 Consumer 端生成的契约直接拷贝到 Provider 端进行验证即可。或者在本地环境使用 Docker 搭建 Pact Broker 也可以。
在服务端验证 Pact 时,验证失败了,在查看 error log 后,发现是由于字段的实际数据类型与期待的数据类型(契约中的数据所对应的类型)不匹配而导致的失败,如图
顺着契约文件向上,查看了客户端(UI)生成契约测试时代码、数据后找到了根因:
在客户端(UI)我们使用的是 Java Script,而 Java Script 是一种弱类型的语言,在 Java Script 中 123,123.0 都是 Number 类型,123 === 123.0 的结果是 true,在前端生成 json 格式的契约文件时,语言将我们的定义的返回值 123.0 自动变成了 123 写入到了契约文件中。
在服务器端我们使用的是 Scala, 而 Scala 是强类型语言,契约文件中的 123 是 Integer,实际返回值 123.0 是 Float,即类型不匹配。
这个问题可以抽象为一类问题,当系统中既有弱类型语言,也有强类型语言时,应当注意不同语言的数据类型不一致问题。
我们的前后端应用,以及契约文件的版本都跟 pipeline 的 build 版本绑定,此时
为了通过前端验证,经常需要去重新执行后端的契约测试太痛苦了,后面打算做些优化,可以通过对 UI 端新生成的契约文件与当前最新版本的契约文件进行 MD5,通过比较摘要判断契约是否发生改变,如果契约没有变化就不需要发布该版本的契约了。
适用场景:
(1)Front-end 与 back-end 间功能测试(基于 Rest API)
(2)基于异步消息的发布订阅的服务
不适用场景:
(1)安全或性能测试
E2E Testing | Contract Testing | |
---|---|---|
测试框架 | E2E Testing 采用一种框架/工具来编写,测试 case 集中维护 | Contract Testing,Contract 的生成跟验证分布在 Consumer 跟 Provider 两端,分开维护;Consumer 跟 Provider 采用不同的技术栈语言。 |
执行测试 | 在 Consumer 跟 Provider 部署成功后执行测试 | Contract 测试通过 contract 将一个 E2E Testing 解耦成了两个独立的集成测试,Consumer 端跟 Provider 端都可以进行独立的测试跟部署 |
测试速度与稳定性 | E2E Testing 是在前后端都部署完成的情况下进行黑盒测试,测试运行速度慢,真实环境不稳定,有可能导致随机失败,测试失败后原因难定位 | 而 Contract 测试 Consumer 跟 Provider 两端可以独立测试,基于 Contract 相对稳定,测试时间速度快 |
测试角度与测试目的 | E2E Testing 站在用户角度来测试整个系统应用的功能,更接近用户真实使用场景 | 相比 E2E TestingContract 测试站在 Consumer 跟 Provider 的角度做了细分,如前端 UI 作为 Consumer,它可能有多个不同的 Provider,身份认证鉴权 Provider,业务 API Provider, 用户行为数据收集分析 Provider 等。同样的后端服务作为 Provider,它也可能有多个不同的 Consumer, web 端 UI Consumer,移动端 APP Consumer,其他微服务 Consumer 等, Contract Testing 主要关注一个个独立的 Consumer- Provider 对,验证不同 Provider 是否按照期望的方式与 Consumer 进行交互 |
参考资料与推荐: