契约测试
契约测试的思想就是将原本的 Consumer 与 Provider 间同步的集成测试,通过契约进行解耦,变成 Consumer 与 Provider 端两个各自独立的、异步的单元测试。
契约测试的优点:
契约测试与单元测试以及其它测试之间没有重复,它是单纯验证Provider与Consumer之间按预期的方式交互,定位准确;不需要部署真实的系统环境、Mock机制、没有真实API调用,运行非常快、反馈及时、修复周期短、成本低,在这种情况下,自动化测试流水线运行更快了,产品流水线出产品安装包也更快。因此,显然契约测试才是真正对的选择。
契约测试的缺点:
- 契约测试无法做安全或性能测试等。
- 契约测试采用Mock机制,所以没有集成测试更接近真实环境,也不能给业务人员做验收,可视性差。
- 契约测试基于不同的服务使用的协议不同,验证契约的复杂度会不同,复杂度过高时,需要权衡是否有必要加契约测试。
别再加端到端集成测试了,快换契约测试吧 - 简书 (jianshu.com)
基于Consumer驱动的契约测试分两个阶段:
- Consumer生成契约,开发者在Consumer端写测试时Mock掉Provider,运行测试生成契约文件;
- Provider验证契约,开发者拿契约文件直接在Provider端运行测试进行验证。
- 契约测试实践篇
Pact和Spring Cloud Contracts是目前最常用的契约测试框架, Pact 实现就采用 Consumer-driven Contract Testing
Pact
Overview | Pact Docs
Pact 是事实上的 API 合约测试工具。用快速、可靠且易于调试的单元测试取代昂贵且脆弱的端到端集成测试。
- ⚡ 闪电般的速度
- 🎈 轻松的全栈集成测试 - 从前端到后端
- 🔌 支持 HTTP/REST 和事件驱动系统
- 🛠️ 可配置的模拟服务器
- 😌 强大的匹配规则可防止测试变脆
- 🤝 与 Pact Broker / PactFlow 集成,实现强大的 CI/CD 工作流程
- 🔡 支持12+种语言
为什么使用契约?
使用 Pact 进行合同测试可让您:
- ⚡ 本地测试
- 🚀 部署速度更快
- ⬇️ 缩短变更提前期
- 💰 降低 API 集成测试的成本
- 💥 防止中断性更改
- 🔎 了解您的系统使用情况
- 📃 免费记录您的 API
- 🗄 无需复杂的数据夹具
- 🤷 ♂️ 减少对复杂测试环境的依赖
克隆项目
pact有不同语言的版本,这里用的js语言
git clone https://github.com/pact-foundation/pact-js.git
消费者测试
Consumer Tests | Pact Docs
针对消费者完成单元测试,使用测试替身mock server,使单元测试可以通过,并生成契约文件。
(主要是定义契约文件)
运行单个示例
- 切换到所需的示例文件夹
cd examples/v3/typescript
- 安装所有示例依赖项
npm install
- 运行所有示例 -
npm run test
运行后成功显示通过一条用例
问题:运行npm run test提示Cannot find module '@pact-foundation/pact' or its corresponding type declarations.
解决办法:在pact-js目录下执行npm install @pact-foundation/pact,然后再运行npm run test
运行后会生成pacts目录
该目录下生成的是契约文件
消费者是User Web,生产者是User API
{
"consumer": {
"name": "User Web"
},
"interactions": [
{
"description": "a request to get a user",
"providerStates": [
{
"name": "a user with ID 1 exists"
}
],
"request": {
"method": "GET",
"path": "/users/1"
},
"response": {
"body": {
"age": 25,
"id": 1,
"name": "东方不败",
"province": "河南"
},
"headers": {
"content-type": "application/json"
},
"matchingRules": {
"body": {
"$": {
"combine": "AND",
"matchers": [
{
"match": "type"
}
]
}
},
"header": {}
},
"status": 200
}
}
],
"metadata": {
"pact-js": {
"version": "12.1.0"
},
"pactRust": {
"ffi": "0.4.0",
"models": "1.0.4"
},
"pactSpecification": {
"version": "3.0.0"
}
},
"provider": {
"name": "User API"
}
}
源码
index.ts文件
import axios, { AxiosPromise } from 'axios';
export class UserService { //export关键字表示将该类导出为一个模块的公共接口,使其能够在其他模块中被引用和使用。
constructor(private url: string) {}
public getUser = (id: number): AxiosPromise => {
return axios.request({
baseURL: this.url,
headers: { Accept: 'application/json' },
method: 'GET',
url: `/users/${id}`,
});
};
}
user.spec.ts
import * as chai from 'chai';
import * as chaiAsPromised from 'chai-as-promised';
import * as path from 'path';
import * as sinonChai from 'sinon-chai';
import { PactV3, MatchersV3, LogLevel } from '@pact-foundation/pact';
import { UserService } from '../index';
const { like } = MatchersV3;
const LOG_LEVEL = process.env.LOG_LEVEL || 'TRACE';
const expect = chai.expect;
chai.use(sinonChai);
chai.use(chaiAsPromised);
describe('The Users API', () => {
let userService: UserService; //声明了一个变量userService并指定了它的类型为UserService。
// 创建两个应用之间的契约
const provider = new PactV3({ //pact提供的类
consumer: 'User Web',
provider: 'User API',
logLevel: LOG_LEVEL as LogLevel,
});
const userExample = { id: 1, name: '东方不败',age:25,province:"河南" }; //契约
const EXPECTED_BODY = like(userExample);
// 定义测试套件
describe('get /users/:id', () => {
it('returns the requested user', () => {// 定义测试用例1 一个it是一个测试用例
//
provider
.given('a user with ID 1 exists')
.uponReceiving('a request to get a user')
.withRequest({ //请求信息
method: 'GET',
path: '/users/1',
})
.willRespondWith({ //响应信息
status: 200,
headers: { 'content-type': 'application/json' },
body: EXPECTED_BODY,
});
return provider.executeTest(async (mockserver) => { //执行测试
// Act
userService = new UserService(mockserver.url); //模拟了一个url
const response = await userService.getUser(1); //获取到response
// 校验response的data与契约相同
expect(response.data).to.deep.eq(userExample);
});
});
});
});
原理
1、消费者使用pact提供的mock完成单元测试
2、pact把交互写入契约文件(通常是一个json文档)
3、消费者将契约分布给中间人(或者分享出去)
4、pact收到契约,并使用本地运行的provider重放请求
5、提供者在契约测试中需要去除依赖,来确保测试更快速和确定。
在pact-js的doc目录可以看到用户手册。
Consumer API有很多属性:
| `new PactV3(options)` | 为proverder API创建mock server test替身
| `addInteraction(...)` | `V3Interaction` | 注册交互
| `given(...)` | `ProviderStateV3` | 交互中提供者的状态 |
| `uponReceiving(...)` | string | 场景名称,在契约文件中given和uponReceiving必须唯一。
| `withRequest(...)` | `V3Request` | The HTTP 请求信息
| `willRespondWith(...)` | `V3Response` | The HTTP响应信息 |
| `executeTest(...)` | - |执行用户定义函数,如果执行成功,会更新契约文件。
new PactV3的构造参数:
| Parameter | Required? | Type | Description
| ------------------- | --------- | ------- | --------------------------------------------------------------------------------------------------------
| `consumer` | yes | string | 消费者名称
| `provider` | yes | string | 生产者名称
| `port` | no | number | 运行mock服务的端口,默认是随机
| `host` | no | string | 运行mock服务的地址, defaults to 127.0.0.1
| `tls` | no | boolean | 系诶一 (default false, HTTP)
| `dir` | no | string | 契约文件输出目录
| `log` | no | string | 日志文件
| `logLevel` | no | string | 日志级别Log level: one of 'trace', 'debug', 'info', 'error', 'fatal' or 'warn'
| `spec` | no | number | Pact的版本 (defaults to 2)
| `cors` | no | boolean |允许跨域,默认是false
| `timeout` | no | number | The time to wait for the mock server tq5o start up in milliseconds. Defaults to 30 seconds (30000)
第一步是为Consumer API创建一个test
例子采用的是Mocha框架
1)创建契约项目
2)启动Mock provider来替代真正的Provider
3 ) 添加消费者期望结果
4)完成test
5) 验证Consumer和Mock service之间产生的交互(即运行代码,看是否pass)
6)产生契约文件 (代码运行完就会产生契约文件)
生产者测试
Matching | Pact Docs
一个provider测试的输入是一个或者多个契约文件,Pact验证provider符合这些契约文件。
在简单的示例下,可以使用本地契约文件验证provider,但在实际使用时,应该使用Pact Broker来管理契约或者CI/CD工作流
1、启动本地的Provider服务
2、可选的,检测 API 以配置提供程序状态
3、运行provider验证步骤
一旦为消费者创建了契约,就应该用Provider来验证这些契约。Pact提供了如下API。
Verification Options:
参数 | 是否必须 | 类型 | 描述 |
providerBaseUrl | TRUE | string | provider的基础url |
pactBrokerUrl | false | string | pact broker的base url |
provider | false | string | provider的name |
consumerVersionSelectors | false | ConsumerVersionSelector|array | pe配置验证的版本 |
consumerVersionTags | false | string|array | 使用标签取出最新的契约 |
providerVersionTags | FALSE | string|array | 应用到provider的标签 |
providerVersionBranch | FALSE | string | 分支 |
includeWipPactsSince | FALSE | string | |
pactUrls | FALSE | array | 本地契约文件路径数组或者基于HTTP的url,如果不用Pact Broker则该项必须 |
providerStatesSetupUrl | FALSE | string | 该参数已废弃 |
stateHandlers | FALSE | object | |
requestFilter | FALSE | function (Express middleware) | 改变请求或者输出 |
beforeEach | FALSE | function | 在每一个交互验证前执行的函数 |
afterEach | FALSE | function | 在每一个交互验证后执行的函数 |
pactBrokerUsername | FALSE | string | Pact Broker的验证username |
pactBrokerPassword | FALSE | string | Pact Broker的验证password |
pactBrokerToken | FALSE | string | Pact Broker的验证token |
publishVerificationResult | FALSE | boolean | 发布验证结果至Broker,只有在持续集成时才设置这个参数 |
providerVersion | FALSE | string | provider的版本 |
enablePending | FALSE | boolean | 挂起契约 |
timeout | FALSE | number | 超时时间,默认是30秒 |
logLevel | FALSE | string | 不需要,log级别在环境变量中设置 |
最好将契约验证测试作为单元测试套件的一部分,因为可以很方便的使用stubbing,lac或者其他工作。
const { Verifier } = require('@pact-foundation/pact');
// (1) Start provider locally. Be sure to stub out any external dependencies
server.listen(8081, () => {
importData();
console.log('Animal Profile Service listening on http://localhost:8081');
});
// (2) Verify that the provider meets all consumer expectations
describe('Pact Verification', () => {
it('validates the expectations of Matching Service', () => {
let token = 'INVALID TOKEN';
return new Verifier({
providerBaseUrl: 'http://localhost:8081', // <- location of your running provider
pactUrls: [ path.resolve(process.cwd(), "./pacts/SomeConsumer-SomeProvider.json") ],
})
.verifyProvider()
.then(() => {
console.log('Pact Verification Complete!');
});
});
});
匹配规则
可以使用正则表达式或者基于对象类型匹配或者数组来验证相应的结构
匹配规则取决于契约文件