背景
在当前微服务和前后端分离大行其道的行业背景下,越来越多的团队采用了前后端分离和微服务的架构风格。
该服务架构下会让各个服务之间更多的依赖关系,而且通常每个服务都是独立的团队维护,服务和服务之间大多通过API调用。那么这种情况下可能就会出现一个问题,想象一下以下的场景:
A团队开发某服务并提供对应API服务,B团队是A团队的使用者调用A团队的API。

A团队埋头苦干,B团队也争分夺秒,两边都开发完了,往往一联调,就出现下图这情况。

为了保证API调用的准确性, 我们需要对API进行测试。但是即使这次测试通过了,可能会随着迭代的演进,重构等,A团队可能无形中修改了原API,那么这就可能会让B团队在不知情的情况下服务不可用。
对测试而言也可能因为A团队未完成对应API服务或者A服务不稳定,而照成测试无法介入B团队的测试或者测试效率低下。
当然你可能为了早点让B团队可测,你可能构建测试替身,例如使用MockService,构建A团队的服务替身。看上去貌似可以测试提前又可以避免A服务的不稳定性,但是问题又来了,即使Mock测试通过了,你能确定AB团队联调就能通过?也许A团队返回的根本不是你Mock类型的数据或者说A团队的服务发生了变化。

问题和困境
- API调用方对API提供方的变更经常需要通过对API的测试来感知。
- 直接依赖真实API的测试效果受限与API提供方的稳定性和反应速度。
解决方案
解决方式首先是依赖关系的解耦,去掉直接对外部API的依赖,而是内部和外部系统都依赖于一个双方共同认可的约定—“契约”,并且约定内容的变化会被及时感知;其次,将系统之间的集成测试,转换为由契约生成的单元测试,例如通过契约描述的内容,构建测试替身。这样,同时契约替代外部API成为信息变更的载体。

什么是契约测试
契约测试也叫消费者驱动测试。
两个角色:消费者(Consumer)和 生产者(Provider)
一个思想:需求驱动(消费者驱动)
契约文件:由Consumer端和Provider端共同定义的规范,包含API路径,输入,输出。通常由Consumber生成。
实现原理:Consumer 端提供一个类似“契约”的东西(如json 文件,约定好request和response)交给Provider 端,告诉Provider 有什么需求,然后Provider 根据这份“契约”去实现。
PACT demo 分享
PACT 工作原理
PACT 是契约测试其中一个主流框架,最早是ruby实现,现支持大部分开发语言,下图是PACT的工作原理图

第一步:Consumer 写一个对接口发送请求的单元测试,在运行这个单元测试的时候,Consumer 会向Pact发起一个请求,Pact会将服务提供者自动用一个MockService代替返回,并生成Json格式的契约文件。
第二步:在Provider端做契约验证测试。将Provider服务启动起来以后,通过一些命令,Pact会自动按照契约生成接口发起Provider服务调用请求,并验证Provider的接口响应是否满足契约中的预期。
所以可以看到这个过程中,在消费者端不用启动Provider,在服务提供端不用启动Consumer,却完成了与集成测试类似的验证测试。
Node.js 代码实践
- 创建一个Express项目
- 安装Pact相关包
npm install --save-dev pact
3.创建test/consumer/consumer.js文件,编写Consumer调用Provider方法,例子是再封装成一个API:
server.use(bodyParser.json())server.use(bodyParser.urlencoded({ extended: true }))server.get('/1', function (req, res) {var reqOpts = {uri: `http://localhost:8081/user/1`,headers: { 'Accept': 'application/json' },json: true}console.log(`**** Triggering request to http://localhost:8081} ****`)request(reqOpts).then(function (rep) {console.log(rep);console.log('**** Received response ****');res.send(rep)}).catch(function (err) {res.status(500).send(err)})});server.listen(8080, function () {console.log(`**** Consumer listening on 8080. Provider: http://localhost:8081} ****`)})
- 创建test/consumer/consumer-test.js文件,主要用于生成pact的契约文件
1)创建一个Pact对象,其对象表示依赖的一个生产者
// 1. 创建一个Pact对象,其表示依赖的一个生产者端const provider = pact({consumer: 'TodoApp',provider: 'TodoService',port: 8080,log: path.resolve(process.cwd(), 'logs', 'pact.log'),dir: path.resolve(process.cwd(), 'pacts'), // json文件生成位置logLevel: 'INFO',spec: 2});
- 需要调用provider.setup() 启动mock server来mock生产者服务
3)定义消费者与生产者相互交互的内容,与前一步代码结合后如下
// 2. 启动mock server来mock生产者服务provider.setup()// 3. 定义消费者与生产者相互交互的内容.then(() => {provider.addInteraction({state: 'have a matched user',uponReceiving: 'a request for get user',withRequest: {method: 'GET',path: '/1'},willRespondWith: {status: 200,headers: { 'Content-Type': 'application/json; charset=utf-8' },body: {id: 1,name: 'God'}}})}).then(() => done())});
这里需要说明的是state, 这里的state代表的是生产者所处的状态,生产者可以根据不同的状态初始化不同的资源。因而消费者在不同状态下发送同样的请求,生产者却因为自身初始化资源的不同可以返回不同的结果。
4)测试代码中需要有发送请求到mock的生产者服务
// 4. 测试代码中需要有逻辑请求mock的生产者服务it('should response with user with id and name', (done) => {request.get('http://localhost:8080/1').then((response) => {const user = response.body;expect(user.name).to.equal('God');provider.verify();done();}).catch((e) => {console.log('error', e);done(e);});});
5 将契约写到文件中,关闭mock的生产者端
// 5. 将契约写到文件中,关闭mock的生产者端after(() => {provider.finalize();});
这时你启动消费者服务,并运行该test,你将会在工程根目录多出pacts文件夹,并发现生成了todoapp-todoservice.json文件,这个就是契约文件,上面描述了请求的发起路径方法,返回的headers body等:
{"consumer": {"name": "TodoApp"},"provider": {"name": "TodoService"},"interactions": [{"description": "a request for get user","providerState": "have a matched user","request": {"method": "GET","path": "/1"},"response": {"status": 200,"headers": {"Content-Type": "application/json; charset=utf-8"},"body": {"id": 1,"name": "God"}}}],"metadata": {"pactSpecification": {"version": "0.0.0"}}}
生成了契约文件,也就意味着消费者端已经给出了具体的接口返回字段需求,剩下的就是生产者端根据契约文件开发对应接口。
生产者端测试
为了demo简单,这里面把provider和Consumer放一个工程,实际工作通常是两个工程甚至两个不同开发语言的工程。 而且代码直接编写了一个返回固定值的API,并编写了test用于测试生产者端是否满足消费者端。
- 创建test/provider/provider.js文件,新建一个/user/:id接口
server.use((req, res, next) => {res.header('Content-Type', 'application/json')next()});server.get('/user/:id', (req, res) => {res.end(JSON.stringify({id: 1,name: 'God'}));});server.listen(8081, () => {console.log('User Service listening on http://localhost:8081')});
- 创建test/provider/ptest.js文件,用于测试生产者端是否满足消费者的需求
const verifier = require('pact').Verifier;const path = require('path');// 验证生产者满足消费者的需求describe('Pact Verification', () => {it('should validate the expectations of Matching Service', () => {const opts = {providerBaseUrl: 'http://localhost:8081',providerGetUser: 'http://localhost:8081/user/:id',pactUrls: [path.resolve(process.cwd(), './pacts/todoapp-todoservice.json')]// pactUrls: ['http://sjqasystst04:8081/pacts/provider/TodoService/consumer/TodoApp/latest']}return verifier.verifyProvider(opts).then(output => {console.log('Pact Verification Complete!');console.log(output)});});});
其中opts中定义了生产端的URL,要测试的接口URL,以及使用的契约文件,并使用
require('pact').Verifier.verifyProvider 来验证契约
- 启动生产者端,并运行该test,会发现测试是通过的。如果修改下生产者端的接口返回,再次启动生产者端并运行该test会发现测试是不会通过。
Pact Broker
消费者端生成的契约文件,如果没有一个平台管理,势必会比较麻烦,而Pact Broker是契约的管理者(代理人)。它提供了:
- 发布和获取契约的接口
- 服务之间的依赖关系
- 契约的版本管理
例如上面例子,当我把契约发布到Pact Broker上后,生产者端可以修改pactUrls获取对应的契约文件如:
pactUrls: ['http://sjqasystst04:8081/pacts/provider/TodoService/consumer/TodoApp/latest']
Pact Broker Docker服务搭建
1.新建docker-compose.yml,如下:
db:image: postgres:9.4container_name: pactbrokerdbenvironment:- POSTGRES_USER=pact- POSTGRES_PASSWORD=testweb:image: dius/pact-brokercontainer_name: pactbrokerwebports: ["8081:80"]links: ["db"]environment:- PACT_BROKER_DATABASE_USERNAME=pact- PACT_BROKER_DATABASE_PASSWORD=test- PACT_BROKER_DATABASE_HOST=db- PACT_BROKER_DATABASE_NAME=pact
注意:新版是叫pact-broker 旧版是 pact_broker
- 消费者端创建publishPacts.js来发布契约
pact.publishPacts({pactUrls: [path.join(process.cwd(), 'pacts')], // 契约路径pactBroker: 'http://sjqasystst04:8081', // pact broker 服务地址consumerVersion: '1.0.0'});
- 运行该文件,这时你就可以到pact broker上看到契约



API文档上的黑色体字有没发现都是消费者端生成时设置的。
灵活匹配
消费者端制定的契约中每个字段不一定需要完全匹配,其也支持正则匹配、类型匹配、数组匹配。
正则匹配
'gender': term({matcher: 'F|M',generate: 'F'})
类型匹配
body: {id: like(1),name: like('Billy')}
数组匹配
'users': eachLike({name: like('God')}, {min: 2});
min的默认值是1, 这里表示至少有2个user。
小结
契约测试帮我们解决了上面问题?
- 可以使得消费端和提供端之间测试解耦,不再需要客户端和服务端联调才能发现问题
- 完全由消费者驱动的方式,消费者需要什么数据,服务端就给什么样的数据,数据契约也是由消费者来定的
- 测试前移,越早的发现问题,保证后续测试的完整性
- 通过契约测试,团队能以一种离线的方式(不需要消费者、提供者同时在线),通过契约作为中间的标准,验证提供者提供的内容是否满足消费者的期望。
资料参考
JS测试之Pact测试
聊一聊契约测试
nodejs pact
