背景

在当前微服务和前后端分离大行其道的行业背景下,越来越多的团队采用了前后端分离和微服务的架构风格。
该服务架构下会让各个服务之间更多的依赖关系,而且通常每个服务都是独立的团队维护,服务和服务之间大多通过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. 创建一个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
  });
  1. 需要调用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用于测试生产者端是否满足消费者端。

  1. 创建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')
});
  1. 创建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 来验证契约

  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,如下:

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

  1. 消费者端创建publishPacts.js来发布契约
pact.publishPacts({
  pactUrls: [path.join(process.cwd(), 'pacts')], // 契约路径
  pactBroker: 'http://sjqasystst04:8081', // pact broker 服务地址
  consumerVersion: '1.0.0'
});
  1. 运行该文件,这时你就可以到pact broker上看到契约
    契约测试 - 图6
    契约测试 - 图7
    契约测试 - 图8
    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

本文源码

https://github.com/MeYoung/pact_demo