背景

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

A团队开发某服务并提供对应API服务,B团队是A团队的使用者调用A团队的API。
契约测试 - 图1
A团队埋头苦干,B团队也争分夺秒,两边都开发完了,往往一联调,就出现下图这情况。
契约测试 - 图2

为了保证API调用的准确性, 我们需要对API进行测试。但是即使这次测试通过了,可能会随着迭代的演进,重构等,A团队可能无形中修改了原API,那么这就可能会让B团队在不知情的情况下服务不可用。

对测试而言也可能因为A团队未完成对应API服务或者A服务不稳定,而照成测试无法介入B团队的测试或者测试效率低下。

当然你可能为了早点让B团队可测,你可能构建测试替身,例如使用MockService,构建A团队的服务替身。看上去貌似可以测试提前又可以避免A服务的不稳定性,但是问题又来了,即使Mock测试通过了,你能确定AB团队联调就能通过?也许A团队返回的根本不是你Mock类型的数据或者说A团队的服务发生了变化。
契约测试 - 图3

问题和困境

  • API调用方对API提供方的变更经常需要通过对API的测试来感知。
  • 直接依赖真实API的测试效果受限与API提供方的稳定性和反应速度。

解决方案

解决方式首先是依赖关系的解耦,去掉直接对外部API的依赖,而是内部和外部系统都依赖于一个双方共同认可的约定—“契约”,并且约定内容的变化会被及时感知;其次,将系统之间的集成测试,转换为由契约生成的单元测试,例如通过契约描述的内容,构建测试替身。这样,同时契约替代外部API成为信息变更的载体。
契约测试 - 图4

什么是契约测试

契约测试也叫消费者驱动测试。
两个角色:消费者(Consumer)和 生产者(Provider)
一个思想:需求驱动(消费者驱动)
契约文件:由Consumer端和Provider端共同定义的规范,包含API路径,输入,输出。通常由Consumber生成。
实现原理:Consumer 端提供一个类似“契约”的东西(如json 文件,约定好request和response)交给Provider 端,告诉Provider 有什么需求,然后Provider 根据这份“契约”去实现。

PACT demo 分享

PACT 工作原理

PACT 是契约测试其中一个主流框架,最早是ruby实现,现支持大部分开发语言,下图是PACT的工作原理图

契约测试 - 图5

第一步:Consumer 写一个对接口发送请求的单元测试,在运行这个单元测试的时候,Consumer 会向Pact发起一个请求,Pact会将服务提供者自动用一个MockService代替返回,并生成Json格式的契约文件。
第二步:在Provider端做契约验证测试。将Provider服务启动起来以后,通过一些命令,Pact会自动按照契约生成接口发起Provider服务调用请求,并验证Provider的接口响应是否满足契约中的预期。

所以可以看到这个过程中,在消费者端不用启动Provider,在服务提供端不用启动Consumer,却完成了与集成测试类似的验证测试。

Node.js 代码实践

  1. 创建一个Express项目
  2. 安装Pact相关包
    npm install --save-dev pact
    3.创建test/consumer/consumer.js文件,编写Consumer调用Provider方法,例子是再封装成一个API:
  1. server.use(bodyParser.json())
  2. server.use(bodyParser.urlencoded({ extended: true }))
  3. server.get('/1', function (req, res) {
  4. var reqOpts = {
  5. uri: `http://localhost:8081/user/1`,
  6. headers: { 'Accept': 'application/json' },
  7. json: true
  8. }
  9. console.log(`**** Triggering request to http://localhost:8081} ****`)
  10. request(reqOpts)
  11. .then(function (rep) {
  12. console.log(rep);
  13. console.log('**** Received response ****');
  14. res.send(rep)
  15. })
  16. .catch(function (err) {
  17. res.status(500).send(err)
  18. })
  19. });
  20. server.listen(8080, function () {
  21. console.log(`**** Consumer listening on 8080. Provider: http://localhost:8081} ****`)
  22. })
  1. 创建test/consumer/consumer-test.js文件,主要用于生成pact的契约文件
    1)创建一个Pact对象,其对象表示依赖的一个生产者
  1. // 1. 创建一个Pact对象,其表示依赖的一个生产者端
  2. const provider = pact({
  3. consumer: 'TodoApp',
  4. provider: 'TodoService',
  5. port: 8080,
  6. log: path.resolve(process.cwd(), 'logs', 'pact.log'),
  7. dir: path.resolve(process.cwd(), 'pacts'), // json文件生成位置
  8. logLevel: 'INFO',
  9. spec: 2
  10. });
  1. 需要调用provider.setup() 启动mock server来mock生产者服务

3)定义消费者与生产者相互交互的内容,与前一步代码结合后如下

  1. // 2. 启动mock server来mock生产者服务
  2. provider.setup()
  3. // 3. 定义消费者与生产者相互交互的内容
  4. .then(() => {
  5. provider.addInteraction({
  6. state: 'have a matched user',
  7. uponReceiving: 'a request for get user',
  8. withRequest: {
  9. method: 'GET',
  10. path: '/1'
  11. },
  12. willRespondWith: {
  13. status: 200,
  14. headers: { 'Content-Type': 'application/json; charset=utf-8' },
  15. body: {
  16. id: 1,
  17. name: 'God'
  18. }
  19. }
  20. })
  21. })
  22. .then(() => done())
  23. });

这里需要说明的是state, 这里的state代表的是生产者所处的状态,生产者可以根据不同的状态初始化不同的资源。因而消费者在不同状态下发送同样的请求,生产者却因为自身初始化资源的不同可以返回不同的结果。
4)测试代码中需要有发送请求到mock的生产者服务

  1. // 4. 测试代码中需要有逻辑请求mock的生产者服务
  2. it('should response with user with id and name', (done) => {
  3. request.get('http://localhost:8080/1')
  4. .then((response) => {
  5. const user = response.body;
  6. expect(user.name).to.equal('God');
  7. provider.verify();
  8. done();
  9. })
  10. .catch((e) => {
  11. console.log('error', e);
  12. done(e);
  13. });
  14. });

5 将契约写到文件中,关闭mock的生产者端

  1. // 5. 将契约写到文件中,关闭mock的生产者端
  2. after(() => {
  3. provider.finalize();
  4. });

这时你启动消费者服务,并运行该test,你将会在工程根目录多出pacts文件夹,并发现生成了todoapp-todoservice.json文件,这个就是契约文件,上面描述了请求的发起路径方法,返回的headers body等:

  1. {
  2. "consumer": {
  3. "name": "TodoApp"
  4. },
  5. "provider": {
  6. "name": "TodoService"
  7. },
  8. "interactions": [
  9. {
  10. "description": "a request for get user",
  11. "providerState": "have a matched user",
  12. "request": {
  13. "method": "GET",
  14. "path": "/1"
  15. },
  16. "response": {
  17. "status": 200,
  18. "headers": {
  19. "Content-Type": "application/json; charset=utf-8"
  20. },
  21. "body": {
  22. "id": 1,
  23. "name": "God"
  24. }
  25. }
  26. }
  27. ],
  28. "metadata": {
  29. "pactSpecification": {
  30. "version": "0.0.0"
  31. }
  32. }
  33. }

生成了契约文件,也就意味着消费者端已经给出了具体的接口返回字段需求,剩下的就是生产者端根据契约文件开发对应接口。

生产者端测试

为了demo简单,这里面把provider和Consumer放一个工程,实际工作通常是两个工程甚至两个不同开发语言的工程。 而且代码直接编写了一个返回固定值的API,并编写了test用于测试生产者端是否满足消费者端。

  1. 创建test/provider/provider.js文件,新建一个/user/:id接口
  1. server.use((req, res, next) => {
  2. res.header('Content-Type', 'application/json')
  3. next()
  4. });
  5. server.get('/user/:id', (req, res) => {
  6. res.end(JSON.stringify({
  7. id: 1,
  8. name: 'God'
  9. }));
  10. });
  11. server.listen(8081, () => {
  12. console.log('User Service listening on http://localhost:8081')
  13. });
  1. 创建test/provider/ptest.js文件,用于测试生产者端是否满足消费者的需求
  1. const verifier = require('pact').Verifier;
  2. const path = require('path');
  3. // 验证生产者满足消费者的需求
  4. describe('Pact Verification', () => {
  5. it('should validate the expectations of Matching Service', () => {
  6. const opts = {
  7. providerBaseUrl: 'http://localhost:8081',
  8. providerGetUser: 'http://localhost:8081/user/:id',
  9. pactUrls: [path.resolve(process.cwd(), './pacts/todoapp-todoservice.json')]
  10. // pactUrls: ['http://sjqasystst04:8081/pacts/provider/TodoService/consumer/TodoApp/latest']
  11. }
  12. return verifier.verifyProvider(opts)
  13. .then(output => {
  14. console.log('Pact Verification Complete!');
  15. console.log(output)
  16. });
  17. });
  18. });

其中opts中定义了生产端的URL,要测试的接口URL,以及使用的契约文件,并使用
require('pact').Verifier.verifyProvider 来验证契约

  1. 启动生产者端,并运行该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,如下:

  1. db:
  2. image: postgres:9.4
  3. container_name: pactbrokerdb
  4. environment:
  5. - POSTGRES_USER=pact
  6. - POSTGRES_PASSWORD=test
  7. web:
  8. image: dius/pact-broker
  9. container_name: pactbrokerweb
  10. ports: ["8081:80"]
  11. links: ["db"]
  12. environment:
  13. - PACT_BROKER_DATABASE_USERNAME=pact
  14. - PACT_BROKER_DATABASE_PASSWORD=test
  15. - PACT_BROKER_DATABASE_HOST=db
  16. - PACT_BROKER_DATABASE_NAME=pact

注意:新版是叫pact-broker 旧版是 pact_broker

  1. 消费者端创建publishPacts.js来发布契约
  1. pact.publishPacts({
  2. pactUrls: [path.join(process.cwd(), 'pacts')], // 契约路径
  3. pactBroker: 'http://sjqasystst04:8081', // pact broker 服务地址
  4. consumerVersion: '1.0.0'
  5. });
  1. 运行该文件,这时你就可以到pact broker上看到契约
    契约测试 - 图6
    契约测试 - 图7
    契约测试 - 图8
    API文档上的黑色体字有没发现都是消费者端生成时设置的。

灵活匹配

消费者端制定的契约中每个字段不一定需要完全匹配,其也支持正则匹配、类型匹配、数组匹配。

正则匹配

  1. 'gender': term({
  2. matcher: 'F|M',
  3. generate: 'F'
  4. })

类型匹配

  1. body: {
  2. id: like(1),
  3. name: like('Billy')
  4. }

数组匹配

  1. 'users': eachLike({
  2. name: like('God')
  3. }, {
  4. min: 2
  5. });

min的默认值是1, 这里表示至少有2个user。

小结

契约测试帮我们解决了上面问题?

  • 可以使得消费端和提供端之间测试解耦,不再需要客户端和服务端联调才能发现问题
  • 完全由消费者驱动的方式,消费者需要什么数据,服务端就给什么样的数据,数据契约也是由消费者来定的
  • 测试前移,越早的发现问题,保证后续测试的完整性
  • 通过契约测试,团队能以一种离线的方式(不需要消费者、提供者同时在线),通过契约作为中间的标准,验证提供者提供的内容是否满足消费者的期望。

资料参考

JS测试之Pact测试
聊一聊契约测试
nodejs pact

本文源码

https://github.com/MeYoung/pact_demo