背景
在当前微服务和前后端分离大行其道的行业背景下,越来越多的团队采用了前后端分离和微服务的架构风格。
该服务架构下会让各个服务之间更多的依赖关系,而且通常每个服务都是独立的团队维护,服务和服务之间大多通过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.4
container_name: pactbrokerdb
environment:
- POSTGRES_USER=pact
- POSTGRES_PASSWORD=test
web:
image: dius/pact-broker
container_name: pactbrokerweb
ports: ["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