软件测试之Contract Testing

2022-11-23  质量实验室 

作者:薛金库|QE_LAB

说到Contract Testing,我们或多或少的听说过,不过相对而言,我觉得它没有API测试、E2E Testing那么普遍,那么什么是Contract Testing? 它的应用场景是什么?它的一个测试流程是怎样的?结合我们在项目上的实践,整理下自己对契约测试的理解,也期待与大家一块儿探讨、交流。

Contract Testing是什么

Contract Testing,即契约测试,主要通过隔离检查每个应用/服务与其他应用/服务间的交互(发送消息或接受消息)是否符合契约中的描述,从而验证应用/服务间的集成。在这里契约Contract就显得至关重要。

关于契约,在现实生活中,我们一切商业活动都是围绕契约展开的,从我们个人与公司签订的Employment Contract劳动合同, 到国家与世界组织签订的WTO Agreement, 这些契约(合同/合约/协议)中描述了参与双方的权利与责任(义务)。
而在针对Http的Contract Testing中,接口协议文档也可以看作是一种契约,它定义了客户端与服务端的交互(请求URL,请求方法,数据格式,状态码等),契约中的参与双方分别是客户端(请求方)跟服务端(服务提供方),通过契约可以帮助我们实现客户端与服务端的解耦。

契约测试可以帮助我们解决什么问题?

随着微服务的兴起, 系统也由传统的的单体架构演变成了多个分布式微服务,系统的维护职责也随着划分到了不同的团队,不同的团队负责开发维护不同的服务。

毋庸置疑,微服务带来了很多好处,如:单个服务可以快速独立部署,服务的高可扩,多个服务可以并行开发等,但同时也引入了一些问题,我们如何确保这些微服务的集成没有问题?通常我们会为其添加E2E testing 来进行服务间集成测试,另外我们可以通过更加轻量化的契约测试来确保服务间的集成。

再想象一下这些场景:

  1. 想删除服务的一个废弃的API,但不确定是否还有哪些其他的服务还在用它?
  2. 后端开发实现的API与前端期望不一致,需要rework。
  3. 对于对外提供服务或者基础服务的API,我们通常会添加版本号来进行API的版本控制,当有breaking change时,我们可以通过升级API的版本号来进行快速迭代开发。那么我们又是如何快速准确的知道我所做的change有没有breaking change,如果有,那会影响到哪些服务调用方呢?
  4. E2E Testing 执行花费时间太长,有时因服务不稳定引起随机失败,如何优化?

我们可以尝试通过契约测试来解决这些问题:

  1. 契约测试能够基于契约进行快速反馈,在客户端跟服务端集成测试之前就能够发现问题。

  2. 契约测试流程促使客户端来主导API的接口协议制定,服务端只要确保API能够通过契约验证,就能保证提供的API满足客户端的需求。

  3. 通过契约所维护的Consumer与Provider的关系图,可以明确知道服务有哪些Consumer,breaking change影响了哪些调用方。

  4. 基于Contract,Consumer端跟Provider分别进行独立测试,测试执行速度更快,更稳定。

契约测试如何工作的?

契约测试中的参与方主要有:ConsumerProviderContract, 契约测试的思想就是将原本的Consumer 与Provider间同步集成测试,通过Contract进行解耦,变成Consumer 与Provider端两个各自独立的、异步单元测试

目前我们所说的Contract testing 基本上都指的是Consumer-driven Contract Testing,CDCT 即由Consumer 驱动的契约测试。PactSpring Cloud Contracts是目前最常用的契约测试框架, Pact就采用Consumer-driven Contract Testing实现。

Consumer Driven Contracts,契约测试中通常由客户方(需求方)来驱动生成契约文件,这样做是比较合理的,契约文件中定义了Consumer端所期待与Provider的交互,此处跟TDD的思想很像,以需求为导向,以实现需求为最终目标,以终为始,由客户方(需求方)定义、生成了契约文件后,Provider端只需要去实现符合契约定义的API即可。

Provider端验证契约,每一个定义在契约里的请求都会在Provider端进行重放,主要通过一个Mock Client来模拟Consumer来发送请求从而获取Response,再与契约文件中期望的Response做对比,从而验证契约是否正确。

下图展示了契约测试中的关键步骤,不再赘述,另外可以参阅官方给出的动画演示以更好的理解其步骤:https://pactflow.io/how-pact-works/

图片来自:https://docs.pact.io/img/how-pact-works/summary.png

项目中的契约测试实践

我们的项目是一个前后端分离的项目,前端UI使用React,后端API服务使用Scala,在项目中引入了契约测试前,大家有过一些考虑和争议:

考虑和争议

  1. 契约测试是否有必要?
    项目上已有E2E Testing(只覆盖了个别重要的feature),另外前后端的项目都是由我们组自己来维护,我们团队拥有整个项目的上下文,如果前端做了一些change, 我们也会及时地去修改对应的后端API,或者后端API做了调整, 我们也会相应地去修改前端UI,以及E2E Testing。还有必要添加契约测试来进行保证吗?

  2. 引入契约测试会影响开发进度?

    引入契约测试后除了编写正常的feature代码外,当有breaking change时还得添加或者修改Consumer 和Provider 端契约测试的代码,另外在pipeline上集成了契约测试后,也会增加了pipeline的运行时间(CI/CD的时间)。

权衡与落地

  1. 项目比较注重质量,认可契约测试是一种好的实践,另外大家也都赞成给项目构建比较完整的测试体系,毕竟条件允许的情况下没有人会嫌弃测试多。
  2. 项目上的交付压力不大,我们可以适当降低一些交付速度,来提高项目质量,“我走的很慢,但我从来不会后退”这也是一种前行的速度吧,哈哈。
  3. 相比E2E Testing,在引入契约测试后,我们站在Provider角度对Provider进行了细分,增加了针对不同Provider的测试,这相当于一种比E2E更细粒度的测试,我们也没有再去给E2E添加case,如果能够使用契约测试来替换E2E Testing的话,开发速度不一定会比之前降低。
  4. 契约的维护需要花费时间,在后面的实际交付过程中,给story估点时我们经常会考虑到更新契约测试要花费的时间,因为有时编写功能代码的时间跟修复契约测试所花费的时间是一样的,就算是API单纯的增加字段,这种非breaking change,我们认为这些也是一种契约的变化,需要更新契约来覆盖这些更改, 毕竟契约不维护也就失去其存在的意义。

具体实践

接下来,我们看下项目中的具体实践:
在契约测试中我们会用到上面提到过的开源的契约测试的框架/工具—Pact,pact已经成为契约测试事实上的标准了。

Pact最开始通过Ruby编写,目前也提供了很多其他语言(Ruby/Java/.NET/JavaScript/Go/Scala/Groovy…)下的实现,在我们的项目中Consumer 端我们使用JavaScript 版的Pact JS,Provider端我们使用了Ruby 版的Pact Ruby

生成契约- Consumer 端

在开始之前先要安装Pact包,可以通过npm 来安装:
npm install --save-dev @pact-foundation/pact@latest
在Consumer 端, 我们主要会去描述针对一个个特定的Provider 所期望的行为。

  1. 首先,创建一个pact的Provider对象表示我们所依赖的Provider(这里的Provider就是我们的后端API服务)
const provider = new Pact({
     consumer: 'XXX UI',
     provider: 'XXX Api',
     MOCK_SERVER_PORT,
     spec: 2,
     cors: true,
     pactfileWriteMode: 'merge'
   })

在这里我们定义了一个Provider,指定了Consumer name,Provider name(这块儿定义的名称也是后面自动生成的契约文件中的相应的Consumer 跟Provider的名字)端口号等。

2.启动一个mock server
通过调用provider.setup()方法来启动一个mock server
3.定义Consumer与Provider间的交互内容
通过调用provider.addInteraction ()方法来添加交互,来定义我们期待的Provider端的响应。在每个交互里的state标识了Provider当前所处的某种的状态(Provider states),不同状态下返回不同的响应,怎么理解呢?可以对应到我们写AC或者写测试时BDD Style的Given When Then 中Given部分,即描述了前提条件,在该state下我们会期待provider端给我们什么样的返回。我们可以通过多次调用该方法来设定Provider在不同状态下(如happy pathsad path等)的响应内容。
4.编写测试(正常的单元测试)
5.验证与Provider的交互是否正确,符合预期
6.生成契约文件,关闭mock server

代码示例:

import * as React from 'react'
import { render, waitForElement } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import config from '../../config'
import {Matchers, Pact} from '@pact-foundation/pact'
import { csrfToken, jwtMfaToken } from '../../pages/__data'
jest.mock('purs/Segment')

const MOCK_SERVER_PORT = 8085
config.apiHost = `http://localhost:${MOCK_SERVER_PORT}/api`
// (1) Create the Pact object to represent your provider
const provider = new Pact({
  consumer: 'XXX UI',
  provider: 'XXX Api',
  MOCK_SERVER_PORT,
  spec: 2,
  cors: true,
  pactfileWriteMode: 'merge'
})
// this is the response you expect from your Provider
const EXPECTED_BODY = Matchers.like([
  {
    id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
    active: true,
    name: 'Unicorn'
  },
  {
    id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx2',
    active: false,
    name: 'InactiveCompany'
  }
])

describe('page/Companies', () => {
  beforeAll(() => {
    return provider
      // (2) Start the mock server
      .setup().then(() =>
      // (3) add interactions to the Mock Server
      provider.addInteraction({
        state: "api has a list of user's companies",
        uponReceiving: 'a request for company list',
        withRequest: {
          method: 'GET',
          path: Matchers.term({
            generate: '/api/company',
            matcher: '/api/company$'
          }),
          query: {
            au: 'true',
            online: 'true'
          },
          headers: {
            'X-Tsec-Csrf': csrfToken,
            Cookie: Matchers.term({
              generate: `session=${jwtMfaToken}; tsec-csrf=${csrfToken}`,
              matcher: 'session=.+'
            })
          }
        },
        willRespondWith: {
          status: 200,
          body: EXPECTED_BODY
        }
      })
    )
  })
  // (4) write your test(s)
  it('should display companies page with the data from api', () => {
    const { getByText } = render(
      <MemoryRouter>
        <Companies />
      </MemoryRouter>
    )
    return waitForElement(() => getByText('ActiveCompany')).then(() =>
      // (5) validate the interactions you've registered and expected occurred
      expect(() => provider.verify()).not.toThrow()
    )
  })

  // (6) write the pact file for this consumer-provider pair,
  // and shutdown the associated mock server.
  afterAll(() => {
    return provider.finalize()
  })
})

查看在测试执行时输出的log

跑完测试后你会在pacts 目录下看到生成的json格式的pact契约文件:xxx_ui-xxx_api.json,其中定义了Consumer与Provider间所有的交互的请求、响应,匹配规则等。

{
  "consumer": {
    "name": "XXX UI"
  },
  "provider": {
    "name": "XXX Api"
  },
  "interactions": [
    {
      "description": "a request for company list",
      "providerState": "api has a list of user's companies",
      "request": {
        "method": "GET",
        "path": "/api/company",
        "query": "au=true&online=true",
        "headers": {
          "X-Tsec-Csrf": "XXX",
          "Cookie": "session=XXX"
        },
        "matchingRules": {
          "$.path": {
            "match": "regex",
            "regex": "\\/api\\/company$"
          },
          "$.headers.Cookie": {
            "match": "regex",
            "regex": "session=.+"
          }
        }
      },
      "response": {
        "status": 200,
        "headers": {
        },
        "body": [
          {
            "cdfId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
            "active": true,
            "name": "Unicorn"
          },
          {
            "cdfId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
            "active": false,
            "name": "InactiveButRegisteredUnicorn"
          }
        ],
        "matchingRules": {
          "$.body": {
            "match": "type"
          }
        }
      }
    }
  ],
  "metadata": {
    "pactSpecification": {
      "version": "2.0.0"
    }
  }
}

关于Pact JS的具体的使用可以参考官方示例
https://www.baeldung.com/pact-junit-consumer-driven-contracts

同样的,如果我们的Consumer 端是一个Java 微服务,其Provider是一个外部的REST服务,这时候我们可以使用Pact 针对Java的实现版本,具体可参考Java Contract Test

对CI/CD的修改:

  1. 在test的步骤就是增加了集成测试的case,正常的跑单元测试即可,不过需要在跑完测试后将新生成的契约文件上传到Pact Broker(契约的存储管理器)上。
  2. 在部署前我们需要确保Consumer端的change没有打破契约,需要验证Consumer端最新的契约得到了最新版本的Provider端的验证而且验证通过.

我们使用pact_broker-client

提供的命令 can-i-deploy 来查询最新版本的契约校验记录是否存在。

如果记录存在:

如果记录不存在:

you should not pass

验证契约- Provider 端

在Provider端,我们会重放契约文件中定义的交互请求。在验证时,契约中的每个交互都会被拿来进行单独验证,每个交互都是独立、隔离的,彼此间互不干扰,不依赖上一个交互的执行结果。

  1. 指定契约文件路径(可以是远端的Pack Broker的URL,或者本地文件路径),Provider端每次做契约验证时,都会去跟最新的契约来进行验证。

  2. 在Provider端,针对契约中定义的每一个交互,我们需要根据交互中定义的Provider State来做些准备数据,Pact 测试框架会帮我们在执行验证每一个交互前,执行我们所定义的数据准备task

  1. 接下来执行pact verify并将pact verify结果同步到Pact Broker上。

在CI/CD上,我们给Provider端添加了Pact测试step:

执行Pact 校验过程中输出的log:

契约的存储管理- Pact Broker

Pact Broker 是一个工具可以帮我们实现在Consumer跟Provider间共享pact契约文件,以及共享契约测试的结果,同时它可以管理不同版本pact文件,以及可视化服务间的关系。

Pact Broker也提供了docker 镜像,可以快速部署Pact Broker。
契约发布

Consumer端可以创建 publishPacts.js , 通过执行 node publishPacts.js 即可将pacts目录下的契约发布到pact broker上。

const pact = require('@pact-foundation/pact-node');
const path = require('path');

pact.publishPacts({
  pactUrls: [path.join(process.cwd(), 'pacts')],
  pactBroker: 'http://localhost:8080',
  consumerVersion: '1.0.0'
});

Pact Broker的主界面

点击查看契约


查看契约版本校验状态

查看服务间关系

注:在local 环境自己做契约测试时可以不用搭建pact broker,先运行Consumer端的测试,跑完测试后会生成契约,将Consumer端生成的契约直接拷贝到Provider端进行验证即可。或者在本地环境使用Docker 搭建Pact Broker也可以。

踩过的坑

数据类型不一致导致pact 验证失败

在服务端验证Pact时,验证失败了,在查看error log后,发现是由于字段的实际数据类型与期待的数据类型(契约中的数据所对应的类型)不匹配而导致的失败,如图

顺着契约文件向上,查看了客户端(UI)生成契约测试时代码、数据后找到了根因:

在客户端(UI)我们使用的是Java Script,而Java Script是一种弱类型的语言,在Java Script中 123,123.0 都是Number类型,123 === 123.0 的结果是true,在前端生成json格式的契约文件时,语言将我们的定义的返回值123.0 自动变成了123写入到了契约文件中。

在服务器端我们使用的是Scala, 而Scala是强类型语言,契约文件中的123是Integer,实际返回值 123.0是Float,即类型不匹配。

这个问题可以抽象为一类问题,当系统中既有弱类型语言,也有强类型语言时,应当注意不同语言的数据类型不一致问题。

契约验证的痛

我们的前后端应用,以及契约文件的版本都跟pipeline 的build 版本绑定,此时

  1. 如果单纯是API后端进行了修改, 此时契约没有变化,如果API后端没有打破契约,即可部署成功;
  2. 如果单纯是UI前端进行了修改,此时都会生成新版的契约文件,部署前端就会失败,因为该版本的契约文件尚未得到验证,需要重新执行一下服务器端的契约测试,契约验证通过后,前端方可部署成功。

为了通过前端验证,经常需要去重新执行后端的契约测试太痛苦了,后面打算做些优化,可以通过对UI端新生成的契约文件与当前最新版本的契约文件进行MD5,通过比较摘要判断契约是否发生改变,如果契约没有变化就不需要发布该版本的契约了。

Contract Testing 总结

适用场景

适用场景:
(1)Front-end与back-end间功能测试(基于Rest API)
(2)基于异步消息的发布订阅的服务
不适用场景:
(1)安全或性能测试

Contract Testing VS E2E Testing

E2E Testing Contract Testing
测试框架 E2E Testing采用一种框架/工具来编写,测试case 集中维护 Contract Testing,Contract的生成跟验证分布在Consumer跟Provider两端,分开维护;Consumer跟Provider采用不同的技术栈语言。
执行测试 在Consumer跟Provider部署成功后执行测试 Contract 测试通过contract将一个E2E Testing解耦成了两个独立的集成测试,Consumer端跟Provider端都可以进行独立的测试跟部署
测试速度与稳定性 E2E Testing是在前后端都部署完成的情况下进行黑盒测试,测试运行速度慢,真实环境不稳定,有可能导致随机失败,测试失败后原因难定位 而Contract 测试Consumer跟Provider两端可以独立测试,基于Contract相对稳定,测试时间速度快
测试角度与测试目的 E2E Testing站在用户角度来测试整个系统应用的功能,更接近用户真实使用场景 相比 E2E TestingContract测试站在Consumer跟Provider的角度做了细分,如前端UI作为Consumer,它可能有多个不同的Provider,身份认证鉴权Provider,业务API Provider, 用户行为数据收集分析Provider等。同样的后端服务作为Provider,它也可能有多个不同的Consumer, web端UI Consumer,移动端APP Consumer,其他微服务Consumer等, Contract Testing主要关注一个个独立的Consumer- Provider对,验证不同Provider是否按照期望的方式与Consumer进行交互

参考资料

参考资料与推荐:

288°/2889 人阅读/0 条评论 发表评论

登录 后发表评论