契约测试

背景

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

A团队开发某服务并提供对应API服务,B团队是A团队的使用者调用A团队的API。


image.png

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


image.png

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

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

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


image.png

问题和困境

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

解决方案

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


image.png

什么是契约测试

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

PACT demo 分享

PACT 工作原理

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

image.png

第一步: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:
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} ****`)
})

  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上看到契约


    所有契约

    图形化的关系

    API文档

    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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容

  • 如今,契约测试已经逐渐成为测试圈中一个炙手可热的话题,特别是在微服务大行其道的行业背景下,越来越多的团队开始关注服...
    ariman阅读 12,466评论 28 33
  • 什么是契约 如果从契约产生的阶段来说,现有资料表明最早要追溯到西周时期的《周恭王三年裘卫典田契》,将契约文字刻写在...
    ThoughtWorks阅读 2,923评论 1 12
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,580评论 18 139
  • 正如大家所知,最初QA都是手动执行测试用例,开发人员每修改一个版本,QA就要手动测试一遍,随着功能的不断增加,手动...
    ThoughtWorks阅读 2,808评论 1 18
  • 前言 本文源于一次关于处理契约测试在CI上更新的小讨论。仅抛砖引玉,期待更多地讨论和建议。 上下文铺垫 契约测试多...
    MoZhou阅读 598评论 1 3