API 契约测试:Schema 校验、Pact 与 CI 流水线集成

API 契约测试方法论:Schema 校验、消费者驱动(CDC)与提供者验证、Pact 契约测试框架、Postman/Bruno 集成测试、MSW Mock 服务、Playwright E2E 与 CI 流水线完整实战。

一句话总结:契约测试在消费者与提供者之间建立可验证的契约,早于部署发现问题,是微服务体系中保障 API 向后兼容的核心实践。

1. API 测试金字塔

在 GraphQL / API 工程领域,测试应遵循金字塔分层:

         /\
        /  \     E2E 测试(Playwright)
       /    \    — 验证完整用户流程
      /──────\   
     /        \  契约测试(Pact / Schema Check)
    /          \ — 验证消费者-提供者契约
   /────────────\  
  /              \ 集成测试(Postman / Bruno)
 /                \— 验证服务间协作
/──────────────────\
       单元测试(Jest / Vitest)
       — 验证 Resolver / Handler 逻辑
测试层级数量运行速度反馈周期成本
单元测试最多< 100ms秒级
集成测试中等1-10s分钟级
契约测试较少1-30s分钟级
E2E 测试最少10-60s分钟级

契约测试的独特价值:在消费者代码(如前端)和提供者代码(如后端)之间建立可验证的契约文件。当提供者变更导致契约破坏时,CI 在合并前即可发现问题。


2. Schema 校验

Schema 校验是最轻量的契约保证,确保服务端 Schema 符合预期的结构和类型约束。

2.1 graphql-js 基础校验

import { parse, validate } from "graphql";
import { buildSchema } from "graphql";

const schema = buildSchema(`
  type Query {
    user(id: ID!): User
  }
  type User {
    id: ID!
    name: String!
  }
`);

// 校验查询文档是否合法
const query = parse(`
  query GetUser {
    user(id: "1") {
      id
      name
    }
  }
`);

const errors = validate(schema, query);
console.log(errors); // [] = 无错误

2.2 破坏性变更检测

# 安装 GraphQL Inspector
npm install -g @graphql-inspector/cli

# 比较两个 Schema 文件
graphql-inspector diff old-schema.graphql new-schema.graphql

# 输出示例:
# ✖ Field User.phone removed
# ✔ Enum Status.PENDING added
# ⚠ Field User.email type changed from String to String!

2.3 CI 集成

# .github/workflows/schema-check.yml
name: Schema Validation
on: [pull_request]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run generate-schema > schema.graphql
      - run: npx graphql-inspector diff origin/main:schema.graphql schema.graphql

3. 消费者驱动契约测试(CDC)

3.1 CDC 核心概念

CDC 流程

术语说明
ConsumerAPI 的消费者(前端 / 移动端 / 下游服务)
ProviderAPI 的提供者(后端服务)
Contract消费者与提供者之间的交互约定
Pact契约文件(JSON 格式),记录期望的请求和响应
Broker契约交换中心(Pact Broker),存储和分发契约

CDC 工作流程

1. 消费者编写测试:定义期望的请求和响应
   → 生成 Pact 契约文件
   → 上传到 Pact Broker

2. 提供者获取契约:从 Broker 下载消费者的 Pact 文件
   → 执行 Provider Verification Test
   → 确认是否满足所有契约期望

3. CI 阻断:如果 Provider Verification 失败,PR 被阻断

3.2 Pact 消费者测试(前端)

// tests/pact/consumer.test.ts
import { Pact } from "@pact-foundation/pact";
import { GraphQLInteraction } from "@pact-foundation/pact";

const provider = new Pact({
  consumer: "web-client",
  provider: "user-service",
  port: 1234,
  log: "logs/pact.log",
  dir: "pacts",
});

describe("User API Pact", () => {
  beforeAll(() => provider.setup());
  afterEach(() => provider.verify());
  afterAll(() => provider.finalize());

  it("returns a user by id", async () => {
    await provider.addInteraction(
      new GraphQLInteraction()
        .withOperation("GetUser")
        .withQuery(`
          query GetUser($id: ID!) {
            user(id: $id) {
              id
              name
              email
            }
          }
        `)
        .withVariables({ id: "1" })
        .uponReceiving("a request for user by id")
        .withRequest({ path: "/graphql", method: "POST" })
        .willRespondWith({
          status: 200,
          body: {
            data: {
              user: {
                id: "1",
                name: "Alice",
                email: "alice@example.com",
              },
            },
          },
        })
    );

    const client = new ApolloClient({
      uri: "http://localhost:1234/graphql",
      cache: new InMemoryCache(),
    });

    const result = await client.query({
      query: GET_USER,
      variables: { id: "1" },
    });

    expect(result.data.user.name).toBe("Alice");
  });
});

3.3 Pact 提供者验证(后端)

// tests/pact/provider.test.ts
import { Verifier } from "@pact-foundation/pact";

describe("User Service Pact Verification", () => {
  it("validates expectations", async () => {
    await new Verifier({
      provider: "user-service",
      providerBaseUrl: "http://localhost:4000",
      pactBrokerUrl: "https://pact-broker.example.com",
      pactBrokerToken: process.env.PACT_TOKEN,
      publishVerificationResult: true,
      providerVersionBranch: process.env.GIT_BRANCH,
    }).verifyProvider();
  });
});

3.4 Pact Broker Webhook

# Broker 配置:当契约上传时,触发 Provider 验证
webhooks:
  - consumer: "web-client"
    events:
      - contract_published
    url: "https://ci.example.com/webhooks/pact"
    headers:
      Authorization: "Bearer ${CI_TOKEN}"

4. Postman / Bruno 集成测试

4.1 Bruno:Git 友好的 API 测试

Bruno 是 Postman 的开源替代品,所有测试用例以文件形式存储(.bru),天然支持版本控制。

# 目录结构
api-tests/
├── bruno.json           # 项目配置
├── environments/
│   └── production.bru
└── graphql/
    ├── get-user.bru
    ├── create-post.bru
    └── collection.bru

get-user.bru

meta {
  name: Get User
  type: graphql
  seq: 1
}

post {
  url: {{baseUrl}}/graphql
  body: graphql
  auth: bearer
}

query {
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
    }
  }
  vars {
    id: "1"
  }
}

tests {
  test("status is 200", function() {
    expect(res.status).to.equal(200);
  });
  
  test("user has ID", function() {
    expect(res.body.data.user.id).to.equal("1");
  });
  
  test("response time < 200ms", function() {
    expect(res.responseTime).to.be.below(200);
  });
}

4.2 CLI 运行与 CI 集成

# 运行所有测试
bru run --env production

# 生成报告
bru run --env production --output results.json

# GitHub Actions
npx @usebruno/cli@latest run --env production

5. Mock Service Worker(MSW)

MSW 在浏览器/Node 中拦截网络请求,是前端测试的核心工具。

5.1 GraphQL Handler

// tests/mocks/handlers.ts
import { graphql, HttpResponse } from "msw";

export const handlers = [
  graphql.query("GetUser", ({ variables }) => {
    return HttpResponse.json({
      data: {
        user: {
          id: variables.id,
          name: "MSW User",
          email: "msw@example.com",
        },
      },
    });
  }),

  graphql.mutation("CreatePost", ({ variables }) => {
    return HttpResponse.json({
      data: {
        createPost: {
          id: "new-post-id",
          title: variables.input.title,
        },
      },
    });
  }),

  // 模拟网络错误
  graphql.query("SearchUsers", () => {
    return HttpResponse.error();
  }),
];

5.2 Schema-Driven Mock

// 基于 Schema 自动生成 Mock 数据
import { addMocksToSchema } from "@graphql-tools/mock";
import { makeExecutableSchema } from "@graphql-tools/schema";

const schema = makeExecutableSchema({ typeDefs });
const mockedSchema = addMocksToSchema({ schema });

// 在 MSW 中使用
graphql.operation(({ query }) => {
  const result = graphqlSync({ schema: mockedSchema, source: query });
  return HttpResponse.json(result);
});

6. Playwright E2E 与 API 拦截

6.1 Mock GraphQL 请求

// tests/e2e/user-flow.spec.ts
import { test, expect } from "@playwright/test";

test("user can view profile", async ({ page }) => {
  // Mock GraphQL response
  await page.route("**/graphql", async (route, request) => {
    if (request.postData()?.includes("GetUser")) {
      await route.fulfill({
        status: 200,
        body: JSON.stringify({
          data: {
            user: {
              id: "1",
              name: "E2E User",
              email: "e2e@example.com",
            },
          },
        }),
      });
      return;
    }
    route.continue();
  });

  await page.goto("/profile");
  await expect(page.locator("[data-testid='user-name']")).toHaveText("E2E User");
});

6.2 并行 API 验证

// 同时测试 UI 和 API
const [response] = await Promise.all([
  page.waitForResponse("**/graphql"),
  page.click("button.submit"),
]);

const json = await response.json();
expect(json.data.createPost.title).toBe("New Post");

7. CI/CD 完整流水线

7.1 GitHub Actions Workflow

# .github/workflows/api-test.yml
name: API Test Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  unit-test:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:unit

  schema-check:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run generate-schema
      - run: npx graphql-inspector diff origin/main:schema.graphql schema.graphql

  integration-test:
    needs: [unit-test, schema-check]
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:integration
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/test

  contract-test-consumer:
    needs: unit-test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:pact:consumer
      - name: Upload Pacts
        uses: pact-foundation/pact-broker-publish-action@v1
        with:
          pact-broker-base-url: https://pact.example.com
          pact-broker-token: ${{ secrets.PACT_TOKEN }}
          consumer-app-version: ${{ github.sha }}

  contract-test-provider:
    needs: contract-test-consumer
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run db:migrate  # 准备测试数据库
      - run: npm run test:pact:provider
        env:
          PACT_TOKEN: ${{ secrets.PACT_TOKEN }}

  e2e-test:
    needs: [integration-test, contract-test-provider]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install
      - run: npm run test:e2e
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

7.2 流水线触发策略

阶段触发条件失败阻断
Lint + Type每次 PR✅ 是
Schema Check每次 PR✅ 是(breaking change)
Unit Test每次 PR✅ 是
Integration每次 PR✅ 是
Contract (Consumer)每次 PR / push✅ 是
Contract (Provider)Consumer 上传后通知 Provider 团队
E2E合并到 main 后通知 + issue

8. 测试数据管理

8.1 工厂模式

// factories/user.ts
import { faker } from "@faker-js/faker";

export function createUser(overrides?: Partial<User>): User {
  return {
    id: faker.string.uuid(),
    name: faker.person.fullName(),
    email: faker.internet.email(),
    role: "USER",
    createdAt: faker.date.past(),
    ...overrides,
  };
}

// 使用
const admin = createUser({ role: "ADMIN", name: "Test Admin" });

8.2 环境隔离

环境数据策略
单元测试内存数据库 / Mock
集成测试每次测试事务回滚(PostgreSQL BEGIN; ...; ROLLBACK;
E2E 测试专用测试数据库 + 种子脚本
契约测试独立 Pact Mock Server

9. 一句话总结

  • Schema 校验:GraphQL Inspector + CI 校验,防止 breaking change 流入生产
  • 契约测试(Pact):消费者驱动,Provider 验证,Broker 居中协调
  • 集成测试(Bruno):文件化 API 测试,Git 版本控制友好
  • Mock 测试(MSW):前端拦截网络请求,零后端依赖
  • E2E 测试(Playwright):验证完整用户流程 + API 响应数据
  • CI 流水线:lint → unit → schema → integration → contract → e2e,逐层把关

FAQ

Q1:契约测试和 E2E 测试的区别?

A:契约测试验证"接口约定"(消费者期望的 API 形状),E2E 验证"业务流程"(用户完成某功能的完整路径)。契约测试运行更快(秒级),E2E 更慢(分钟级)。两者互补——契约保证 API 兼容,E2E 保证业务正确。

Q2:Pact Broker 是否必需?

A:小团队(< 5 人)可手动共享契约文件。中型以上团队强烈建议使用 Pact Broker,它提供:契约版本管理、Provider 验证历史、Webhook 触发 CI、can-i-deploy 部署决策。

Q3:如何处理 GraphQL 查询的变体?

A:每个不同的查询形状(字段组合)都视为独立的契约。Pact 支持将操作名作为契约标识。建议使用 Persisted Queries 减少查询变体数量——白名单查询天然约束了契约范围。

Q4:MSW 能否完全替代真实后端测试?

A:MSW 适合单元/组件测试和前端开发期间 Mock。但在集成测试中仍需真实后端验证数据一致性。最佳实践:前端开发用 MSW,集成测试用 Bruno + 真实后端,E2E 用真实全栈。

Q5:Schema Check 能防止所有 breaking change 吗?

A:不能。结构型 breaking change(删除字段、改变类型)可被检测,但语义型 breaking change(字段返回数据格式变更但类型声明不变)无法通过 Schema Check 发现。需结合契约测试和 E2E 覆盖。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「API 工程」更多文章

  1. tRPC 端到端类型安全 API:从路由定义到 Next.js 全栈集成
  2. GraphQL vs REST vs gRPC vs tRPC:API 范式深度对比与选型
  3. GraphQL Schema 演进与版本控制:零破化变更策略