#TypeScript#DevOps

契约测试:消费者驱动 vs 提供者验证

微服务集成测代价高(要起全部服务),契约测试只验证接口约定不变。本文讲清消费者驱动契约测试(Pact):消费者定义期望、生成契约、提供者验证,解耦前后端/服务间的"我的改动有没有破坏别人"。

本文要点

  • 主题聚焦:TypeScript、DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年2月7日·更新于 2026年7月23日

契约测试:消费者驱动 vs 提供者验证

先讲一个每个微服务团队都会撞上的墙。

某天你改了用户服务的一个字段,本地单测全绿,CI 全绿,合并上线。十分钟后,订单服务挂了,前端白屏,告警群炸了。排查一圈发现:你把 user.name 拆成了 firstName / lastName,订单服务还在读 name,前端还在渲染 user.name。

问题不在你的代码——你的代码是对的。问题在于没人知道谁依赖了这个字段。集成测试本来该兜底,但它根本跑不起来:要同时拉起用户服务、订单服务、前端、网关、数据库、消息队列,还要准备一套彼此关联的测试数据。这套环境本身就是一个永远修不完的 bug。

契约测试就是来解决这件事的。它不验证业务对不对,只验证一件事:接口的约定,有没有变。

一、集成测试在微服务里为什么跑不动

先承认一个事实:端到端集成测试在微服务架构里,投入产出比是倒挂的。

经典金字塔测试里,集成测试卡在中间——验证几个模块拼在一起能不能跑。在单体里这事不难,起一个进程、连个测试库就行。但微服务把"模块"变成了"进程",集成测试的代价瞬间爆炸:

  • 要起全部相关服务。验证一个下单流程,可能要拉起订单、用户、库存、支付、通知五个服务,加上它们各自的数据库、缓存、消息中间件。
  • 要准备联动的测试数据。订单服务测试用的用户,得在用户服务里先存在;库存的扣减要和支付的状态对齐。数据准备脚本比测试本身还长。
  • 环境永远在漂移。今天 Docker Compose 能起来,明天某个镜像 tag 升级,某个服务的配置改了,环境就挂了。维护集成环境成了一项全职工作。
  • 失败定位极难。一个集成测试红了,你不知道是哪个服务的问题——是数据没造好,还是网络超时,还是某个服务真的有 bug。Flaky test 泛滥,团队很快就开始忽略红灯。

更致命的是反馈速度。集成测试跑一轮十几分钟,开发者根本不会在本地跑。等 CI 跑完通知你,你早就切到下一个任务了。于是集成测试变成了"上线前的仪式",而不是开发阶段的反馈工具。

集成测试不是没用,但它的成本在微服务里被放大了一个数量级。你不可能对每一个 PR 都跑全套集成测试。于是接口契约这个层面就裸奔了——没人能在合并前知道"我的改动有没有破坏下游"。

契约测试补的就是这个缺口:它把"接口约定是否被遵守"从昂贵的集成测试里拆出来,变成一种廉价的、可以每次提交都跑的、不需要起任何依赖的测试。

二、契约测试的核心思想:只验证约定,不验证逻辑

契约测试的本质是一句话:消费者和生产者之间,对接口的期望,应该有一份可执行的、被版本化管理的文档。

注意这里的关键限定——只验证接口约定。

它验证的它不验证的
请求路径、方法、参数是否一致业务逻辑算得对不对
响应字段是否齐全、类型对不对数据库查询是否正确
状态码、headers 是否符合预期高并发下会不会超时
字段是否被悄悄删除/改名跨服务的完整业务流程

这意味着契约测试和单元测试、集成测试是互补的,不是替代关系。单元测保证你的逻辑对,契约测保证你的接口没漂移,集成测(少量、关键路径)保证拼装没问题。

为什么要做这个切分?因为接口变更的频率和业务逻辑变更的频率,往往不在一个量级。业务逻辑天天改,但 GET /users/:id 返回什么结构,不该天天变。一旦它变了,就是一个跨团队的影响面,必须有人能立刻知道。契约测试就是那个"立刻知道"的机制。

它的价值不是"测出更多 bug",而是让接口变更的影响在合并前可见,把"炸到下游"变成"下游已经验证过"。

三、Pact 的工作原理:消费者驱动契约

Pact 是消费者驱动契约测试(CDC, Consumer-Driven Contracts)最主流的实现。它的核心设计很有意思,理解了这个设计,就理解了它为什么有效。

"消费者驱动"是关键四个字。传统的接口文档是提供者写的——后端写个 Swagger,前端照着调。问题是提供者写的文档,提供者自己不遵守,消费者也不敢质疑。Pact 把话语权翻转了:消费者定义"我期望你返回什么",提供者去证明"我确实返回这个"。

整个流程分两步,在两端分别跑:

text
┌──────────────────────────────────────────────────────────────┐
│  消费者侧(写期望 → 生成契约)                                   │
│                                                              │
│  1. 消费者写测试:我调 GET /users/1,期望返回 { id, name }      │
│  2. Pact 在本地起一个 mock 服务,拦截这个请求                   │
│  3. 测试断言消费者的代码能正确处理这个响应                        │
│  4. 测试通过 → Pact 把"请求+期望响应"序列化成一份 JSON 契约文件    │
└──────────────────────────┬───────────────────────────────────┘
                           │  pact publish(推到 Broker)
                           ▼
┌──────────────────────────────────────────────────────────────┐
│  提供者侧(拉取契约 → 验证)                                     │
│                                                              │
│  1. 提供者从 Broker 拉取所有消费者定义的契约                     │
│  2. Pact 重放每个契约里的请求,打到真实的提供者服务上             │
│  3. 比对真实响应 vs 契约期望响应                                 │
│  4. 全部匹配 → 验证通过;任何一个字段不一致 → 挂掉                │
└──────────────────────────────────────────────────────────────┘

这里有一个精妙的设计:消费者的测试,本身就是契约的生产过程。消费者不是写一份独立的文档,而是写一个真实的测试——用自己的 HTTP 客户端去调 mock,mock 返回它期望的数据,然后断言自己的业务逻辑跑得通。如果消费者对响应结构的期望是错的(比如它期望一个根本不合理的字段),测试在消费者侧就会红,逼着消费者修正。这样生产出来的契约,天然是消费者真实需要的,而不是拍脑袋写的文档。

而提供者侧的验证,是拿这份契约当作请求 fixture 去打真实服务。不需要 mock 任何东西,不需要造数据——Pact 会要求提供者在测试里准备对应的数据状态(Provider States),然后重放请求,比对响应。提供者只要证明"给这些输入,我确实返回这些输出"。

四、实战:消费者侧,用 Pact 定义期望

用 Pact JS 来写。假设我们是前端,要消费后端的用户服务。

先装依赖:

bash
pnpm add -D @pact-foundation/pact @pact-foundation/pact-node

消费者测试的核心是:用真实的 API 客户端去打 Pact 起的 mock 服务,断言你的代码能正确处理响应。

typescript
// src/tests/user-api.pact.test.ts
import path from 'node:path'
import { Pact, MatchersV3 } from '@pact-foundation/pact'
import { getUser } from '../api/user-api' // 你的真实 API 客户端

const { like, eachLike } = MatchersV3

// 创建一个 Pact mock provider
const provider = new Pact({
  consumer: 'web-frontend',
  provider: 'user-service',
  port: 4001,
  dir: path.resolve(__dirname, '../../pacts'),
  log: path.resolve(__dirname, '../logs/pact.log'),
})

describe('用户服务契约', () => {
  beforeAll(() => provider.setup())
  afterEach(() => provider.verify()) // 关键:验证真实请求和 mock 配置一致
  afterAll(() => provider.finalize()) // 关键:测试结束后生成契约文件

  describe('GET /users/:id', () => {
    it('根据 id 查询用户详情', async () => {
      // 第一步:声明期望——我调这个接口,期望它返回这样的结构
      await provider.addInteraction({
        uponReceiving: '一个查询用户详情的请求',
        withRequest: {
          method: 'GET',
          path: '/users/123',
          headers: { Accept: 'application/json' },
        },
        willRespondWith: {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
          // like() 表示"结构要对,值不重要"——只校验类型
          body: {
            id: like(123),
            name: like('Alice'),
            email: like('[email protected]'),
          },
        },
      })

      // 第二步:用真实的 API 客户端去打 mock 服务
      // 注意 base url 要指向 Pact mock(localhost:4001)
      const user = await getUser(123, { baseUrl: 'http://localhost:4001' })

      // 第三步:断言你的业务代码正确处理了这个响应
      expect(user).toEqual({
        id: 123,
        name: 'Alice',
        email: '[email protected]',
      })
    })
  })
})

这段代码有几个关键点要讲清楚:

第一,MatchersV3 的匹配器是灵魂。 like(123) 不是说"值必须是 123",而是说"这个位置必须有一个数字类型的值"。Pact 在生成契约时,会把 like(123) 序列化成一个"匹配规则"——{"matchers": [{"match": "type"}]}。提供者验证时,返回 id: 456 也能通过,但返回 id: "abc" 就挂。这保证契约校验的是结构而非具体测试数据,不会因为提供者的测试数据不同而无谓失败。

第二,provider.verify() 在每个测试后跑。 它干的事是:检查消费者是不是真的按 withRequest 声明的方式发出了请求(路径、方法、headers 对不对)。如果消费者声明"我会发 GET /users/123",但实际代码发的是 GET /user/123(少了 s),这里就会红。这保证了契约反映的是消费者真实的行为。

第三,provider.finalize() 生成契约。 所有测试跑完后,Pact 把所有 interaction 聚合成一份 JSON 文件,写到 pacts/web-frontend-user-service.json。这份文件就是"契约"——后续会被推到 Broker,被提供者拉取验证。

生成的契约长这样(简化版):

json
{
  "consumer": { "name": "web-frontend" },
  "provider": { "name": "user-service" },
  "interactions": [
    {
      "description": "一个查询用户详情的请求",
      "request": {
        "method": "GET",
        "path": "/users/123",
        "headers": { "Accept": "application/json" }
      },
      "response": {
        "status": 200,
        "headers": { "Content-Type": "application/json" },
        "body": { "id": 123, "name": "Alice", "email": "[email protected]" },
        "matchingRules": {
          "body": {
            "$.id": { "match": "type" },
            "$.name": { "match": "type" },
            "$.email": { "match": "type" }
          }
        }
      }
    }
  ]
}

这份 JSON 就是前后端之间的"接口合同"。消费者说"我要这些",提供者要证明"我给的就是这些"。

五、实战:提供者侧,验证契约

现在切到提供者(后端 user-service)的视角。它要做的事很简单:拿到契约,把里面的请求重放到真实服务上,比对响应。

typescript
// src/tests/pact-verification.test.ts
import path from 'node:path'
import { Verifier } from '@pact-foundation/pact'
import { app } from '../app' // 你的 Express/Fastify/Nest 应用实例

describe('Pact 契约验证', () => {
  const server = app.listen(8081)

  afterAll(() => server.close())

  it('满足 web-frontend 的所有契约', () => {
    const opts = {
      provider: 'user-service',
      providerBaseUrl: 'http://localhost:8081',
      // 从本地拉契约(演示用,生产环境从 Broker 拉)
      pactUrls: [path.resolve(__dirname, '../../pacts/web-frontend-user-service.json')],
      // Provider States:告诉提供者"现在要准备什么数据"
      stateHandlers: {
        '用户 123 存在': async () => {
          await db.user.upsert({ id: 123, name: 'Alice', email: '[email protected]' })
          return {} // 测试结束后的清理可以在这里做
        },
        '用户不存在': async () => {
          await db.user.deleteMany({ id: 999 })
          return {}
        },
      },
    }

    return new Verifier(opts).verifyProvider()
  })
})

这里最重要的概念是 Provider States(提供者状态)。注意消费者侧声明的是"一个查询用户详情的请求",但提供者验证时需要知道:为了满足这个契约,我的数据库里该有什么数据?

消费者在写期望时,可以声明状态:

typescript
await provider.addInteraction({
  uponReceiving: '一个查询用户详情的请求',
  // 声明:这个期望依赖提供者处于什么状态
  providerState: '用户 123 存在',
  withRequest: { /* ... */ },
  willRespondWith: { /* ... */ },
})

提供者侧的 stateHandlers 收到 '用户 123 存在' 这个字符串,就往测试库里插一条对应的用户。Pact 重放请求时,真实服务就能返回这条数据。状态机制让契约验证不需要依赖任何外部环境或预置数据——状态是测试自己造的,跑完就清。

验证失败时,Pact 会给出非常精确的 diff:

text
Verifying a pact between web-frontend and user-service
  一个查询用户详情的请求
    has status code 200 (OK)
    has a matching body (FAILED)

Failures:

1) Verifying a pact between web-frontend and user-service Given '用户 123 存在'
   expected response body to contain "email" but was missing
   at $.email

这就是契约测试的杀手锏:它精确地告诉你,哪个字段、在哪一层、因为什么原因失败了。对比集成测试那种"链路一长就不知道哪挂了"的体验,这是质的飞跃。

六、前后端场景:双向保护

Pact 在前后端场景下尤其有效,因为它能双向检测破坏。

后端改了 API,前端没动:后端把 name 改成 fullName。提供者验证跑契约——契约里要求返回 name,现在返回的是 fullName,验证挂。后端在合并前就知道:"我破坏了前端的期望。"要么改回去,要么通知前端一起改。

前端改了期望,后端没动:前端因为新需求需要多一个 avatar 字段,于是改了消费者测试,重新生成契约。这份新契约推到 Broker,后端 CI 拉取验证——契约要求返回 avatar,但后端没这个字段,验证挂。后端就知道:"前端有新需求,我得加这个字段。"

这就是消费者驱动的威力:前端的每一份期望都是一份"需求单",后端必须证明自己满足。没有哪种"接口文档过时了没人更新"的情况——契约就是活文档,验证跑不过就是红,红就必须处理。

七、服务间场景:服务 A 调服务 B

服务间调用是契约测试的主战场。假设订单服务调用用户服务获取用户信息:

text
order-service (消费者) ──调用──> user-service (提供者)

订单服务的团队写消费者测试:

typescript
// order-service 里
const provider = new Pact({
  consumer: 'order-service',
  provider: 'user-service',
  dir: path.resolve(__dirname, '../../pacts'),
})

it('下单时查询用户收货地址', async () => {
  await provider.addInteraction({
    uponReceiving: '下单时查询用户地址',
    providerState: '用户 123 有收货地址',
    withRequest: {
      method: 'GET',
      path: '/users/123',
      query: { fields: 'address' },
    },
    willRespondWith: {
      status: 200,
      body: {
        id: like(123),
        address: like({
          city: like('上海'),
          street: like('南京路 1 号'),
        }),
      },
    },
  })

  const order = await createOrder(123, { baseUrl: 'http://localhost:4001' })
  expect(order.shippingAddress.city).toBe('上海')
})

用户服务的团队跑提供者验证,确保自己仍然满足订单服务的期望。

关键在于:两个团队的 CI 是完全独立的。订单服务团队不需要知道用户服务怎么实现,只需要定义"我要什么"。用户服务团队不需要知道订单服务怎么调,只需要保证"我的接口满足契约"。两个团队通过 Broker 异步协作,谁都不阻塞谁。

这在组织架构上是巨大的解放。康威定律说系统结构反映组织结构,微服务团队最痛的就是跨团队联调。契约测试把"接口对齐"这件事从"开会对齐"变成了"CI 对齐"——契约就是唯一的 truth,跑过就是绿,跑不过就是红。

八、Pact Broker:契约的中心化枢纽

到现在为止,契约文件还只是在本地生成、本地验证。但在真实团队协作里,契约需要在消费者和提供者之间流转。这就是 Pact Broker 的角色。

Pact Broker 是一个独立部署的服务,它解决三件事:

第一,契约的共享与发现。 消费者 pnpm test 跑完,用 pact publish 把契约推到 Broker。提供者 CI 从 Broker 拉取所有指向自己的契约来验证。不需要手动拷贝 JSON 文件,不需要共享 git 仓库。

bash
# 消费者侧:发布契约
pact-broker publish ./pacts \
  --consumer-app-version $(git rev-parse --short HEAD) \
  --broker-base-url https://broker.internal.company.com

# 提供者侧:从 Broker 拉取并验证
pact-broker verify \
  --provider user-service \
  --provider-base-url http://localhost:8081 \
  --broker-base-url https://broker.internal.company.com

第二,版本与标签管理。 Broker 记录每个契约对应的 consumer 版本和 provider 版本。你可以知道"前端 v2.3 这个版本的契约,后端 v1.8 验证通过没"。这在排查"哪个版本开始不兼容"时极其有用。

第三,也是最重要的一点——can-i-deploy 兼容性检查。 这是 Pact 生态最有价值的特性,没有之一。

部署前,CI 跑一条命令:

bash
pact-broker can-i-deploy \
  --pacticipant web-frontend \
  --version $(git rev-parse --short HEAD) \
  --to-environment production \
  --broker-base-url https://broker.internal.company.com

Broker 会查:你要部署的 web-frontend 这个版本,它的契约有没有被所有相关 provider 验证通过?如果有任何一个 provider 还没验证最新契约,这条命令返回非零,CI 直接挂掉,部署被拦截。

这意味着:没有经过双边验证的接口变更,根本进不了生产环境。这就是契约测试在 CI/CD 里的终极形态——它不是一个"跑测试"的动作,而是一道部署门禁。前后端不必同时部署、不必同时在线,但接口兼容性是机器保证的,不靠人记得"哦要通知前端"。

九、局限:契约测试不做什么

把契约测试吹上天是不诚实的。它有明确的能力边界,认不清这个边界,团队会把它当银弹用,然后失望。

第一,它不验证业务逻辑正确性。 契约只规定"接口长什么样",不规定"这个接口算得对不对"。GET /orders/:id 返回了正确结构的订单,但订单总价算错了——契约测试发现不了,那是单元测的活。别指望契约测试替你保证业务正确。

第二,它不验证跨服务的完整流程。 契约是点对点的(一个消费者对一个提供者),它不验证"下单 → 扣库存 → 支付 → 发通知"整条链路的状态一致性。少量关键路径的集成测/E2E 测仍然需要保留。

第三,它不验证非功能特性。 性能、并发、超时、重试、幂等——这些契约测试都不碰。响应慢三秒,契约照样绿。

第四,契约本身需要维护成本。 消费者测试要写,Provider State 要维护,数据状态要造。接口变更频繁时,契约维护也是工作量。如果团队没有把契约测试纳入 CI 习惯,契约很快就会和实际接口脱节,变成又一份过时的文档。

一个诚实的测试策略是分层:

  • 单元测:验证业务逻辑(最多,最快)
  • 契约测:验证接口约定(中等数量,每次提交跑)
  • 集成测/E2E:验证关键链路拼接(少量,成本高)

十、什么时候该上契约测试

不是所有项目都该上 Pact。契约测试的 ROI 取决于你的架构形态和接口变更频率。

该上的场景:

  • 多个服务互相调用,且分属不同团队。 团队边界正是契约测试发挥价值的地方——它让接口对齐不需要开会。
  • 前后端分离,接口频繁迭代。 前端定义期望、后端验证,省掉大量联调时间和"接口文档过时"的坑。
  • 接口是公共 API,有多个外部消费者。 你不知道谁在用你的 API,但你可以让所有消费者的契约都跑在你的 CI 里,任何一个消费者的期望被破坏你都能立刻知道。
  • 部署节奏快,需要机器化的兼容性门禁。 can-i-deploy 在高频部署里是真正的安全网。

不该上的场景:

  • 单体应用,模块间是函数调用。 没有接口边界,契约测试无从谈起。单元测和模块级集成测就够了。
  • 接口极其稳定,半年不改一次。 引入 Pact 的工程成本(学习、CI 集成、Broker 部署)远大于它偶尔带来的收益。杀鸡用牛刀。
  • 只有一个消费者和一个提供者,且在一个团队内。 起个 docker-compose 跑集成测更直接,沟通成本也低。契约测试的规模收益来自于多消费者、多提供者的网络效应。
  • 团队还没有 CI/CD 基础设施。 Pact 的价值依赖 CI 自动化。如果连自动测试都没跑起来,先解决那个问题。

我的实践判断标准很简单:当你发现"改一个接口要逐个通知下游、还要陪他们联调"这件事开始占掉你大量时间时,就该上契约测试了。在那之前,别为了"先进"而引入它。

小结

  • 集成测试在微服务里成本倒挂:要起全部服务、造联动数据、环境永远漂移,反馈慢到对开发毫无用处。
  • 契约测试只验证一件事:接口的约定有没有变。它把接口兼容性从昂贵的集成测里拆出来,变成每次提交都能跑的廉价测试。
  • Pact 的消费者驱动模式是核心:消费者定义"我期望你返回什么"→ Pact 生成 JSON 契约 → 提供者重放请求验证。契约反映的是消费者真实的需要,不是提供者拍脑袋写的文档。
  • Pact Broker + can-i-deploy 把契约测试从"跑测试"升级为部署门禁:没有经过双边验证的接口变更,进不了生产。
  • 契约测试不验证业务逻辑、不验证链路流程、不验证性能。它是测试策略的一层,不是全部。多服务、多团队、接口频繁变更时上;单体、接口稳定、单团队时别上。

金句:契约测试不是测出更多 bug,而是让接口变更的影响在合并前可见——把"炸到下游"变成"下游已经验证过"。

Related

相关推荐