其他测试框架 软件测试之 Contract Testing

QE LAB for QE LAB · 2022年11月23日 · 3501 次阅读

作者:薛金库|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. 项目上的 feature 交付压力不大,我们可以降低一些交付速度,来提高项目质量,“我走的很慢,但我从来不会后退” 这也是一种前行的速度吧,哈哈。
  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 的具体的使用可以参考官方示例

同样的,如果我们的 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 进行交互

参考资料

参考资料与推荐:

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册