結論先講

契約測試不是測 API 能不能動,是測「你答應我的格式有沒有變」。 它填補了 Unit Test 和 E2E Test 之間的關鍵缺口——不需要啟動整個系統,就能確認前後端的約定沒有被偷偷破壞。

真實場景:後端把 user_name 改成 username,Unit Test 全過、後端 Integration Test 全過、但前端整個炸掉。契約測試就是擋這種事的。


什麼是契約測試

契約(Contract)= 前後端之間的約定:

前端期望:
POST /api/orders
Request:  { "product_id": number, "quantity": number }
Response: { "order_id": string, "status": "created" }

後端承諾:
POST /api/orders → 201
回傳 { "order_id": string, "status": string }

契約測試就是自動驗證這個約定有沒有被遵守。

跟其他測試的差異

測試類型測什麼需要什麼速度
Unit Test單一函式邏輯毫秒
契約測試API 介面格式Mock Server
Integration Test多元件互動資料庫、外部服務分鐘
E2E Test完整使用者流程整個系統數分鐘

契約測試的價值:跑得跟 Unit Test 一樣快,但能抓到 Integration 層的問題。


兩種契約測試方法

方法一:Consumer-Driven Contract(CDC)— Pact

核心概念:由前端(Consumer)定義期望,後端(Provider)驗證。

1. 前端寫測試,描述「我期望 API 回什麼」
2. Pact 產生一份契約檔(JSON)
3. 後端拿這份契約,跑自己的 API 驗證是否符合
4. 任何一方改動破壞契約 → 測試失敗

前端(Consumer)測試

// frontend/tests/order.pact.test.js
import { PactV3 } from '@pact-foundation/pact';
 
const provider = new PactV3({
  consumer: 'frontend-app',
  provider: 'order-service',
});
 
describe('Order API Contract', () => {
  it('creates an order', async () => {
    // 1. 定義期望
    provider
      .given('product exists')
      .uponReceiving('a request to create order')
      .withRequest({
        method: 'POST',
        path: '/api/orders',
        headers: { 'Content-Type': 'application/json' },
        body: {
          product_id: 1,
          quantity: 2,
        },
      })
      .willRespondWith({
        status: 201,
        headers: { 'Content-Type': 'application/json' },
        body: {
          order_id: Matchers.string('ord-123'),
          status: Matchers.string('created'),
          total: Matchers.decimal(299.99),
        },
      });
 
    // 2. 執行測試(Pact 會啟動 mock server)
    await provider.executeTest(async (mockServer) => {
      const response = await fetch(`${mockServer.url}/api/orders`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ product_id: 1, quantity: 2 }),
      });
      const data = await response.json();
 
      expect(response.status).toBe(201);
      expect(data.order_id).toBeDefined();
      expect(data.status).toBe('created');
    });
    // 3. Pact 自動產生契約檔 → pacts/frontend-app-order-service.json
  });
});

後端(Provider)驗證

# backend/tests/test_pact_verification.py
# 後端拿前端產生的契約檔來驗證
import pytest
from pact_verifier import PactVerifier
 
def test_order_contract():
    verifier = PactVerifier(
        provider='order-service',
        provider_base_url='http://localhost:8000',
    )
 
    # 設定測試狀態
    verifier.add_state_handler('product exists', setup_product_fixture)
 
    # 驗證契約
    result = verifier.verify(
        pact_url='pacts/frontend-app-order-service.json',
    )
    assert result.success

Pact 的優缺點

優點缺點
前後端獨立測試,不需要對方在線學習曲線較陡
精確到欄位層級的驗證需要 Pact Broker(或共用檔案)
支援多語言(JS/Python/Go/Java/…)初期設定成本高
CI 自動化友善微服務多時,契約管理變複雜

方法二:Schema-Based — OpenAPI Validation

核心概念:用 OpenAPI(Swagger)規格檔當作契約,自動驗證 request/response。

# openapi.yaml
paths:
  /api/orders:
    post:
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [product_id, quantity]
              properties:
                product_id:
                  type: integer
                quantity:
                  type: integer
                  minimum: 1
      responses:
        '201':
          content:
            application/json:
              schema:
                type: object
                required: [order_id, status]
                properties:
                  order_id:
                    type: string
                  status:
                    type: string
                    enum: [created, pending]

用 Supertest + OpenAPI 驗證

// backend/tests/api-contract.test.js
import request from 'supertest';
import jestOpenAPI from 'jest-openapi';
 
// 載入 OpenAPI spec
jestOpenAPI('openapi.yaml');
 
describe('Order API matches OpenAPI spec', () => {
  it('POST /api/orders', async () => {
    const res = await request(app)
      .post('/api/orders')
      .send({ product_id: 1, quantity: 2 })
      .expect(201);
 
    // 自動驗證 response 符合 OpenAPI schema
    expect(res).toSatisfyApiSpec();
  });
 
  it('rejects invalid request', async () => {
    const res = await request(app)
      .post('/api/orders')
      .send({ product_id: 'abc' })  // 型別錯誤
      .expect(400);
 
    expect(res).toSatisfyApiSpec();
  });
});

Schema-Based 的優缺點

優點缺點
如果已有 OpenAPI 規格,成本很低只驗格式,不驗業務邏輯
文件和測試合一前端的期望沒有被明確表達
工具成熟(Swagger UI、Redoc)OpenAPI spec 要維護
簡單直觀跨服務的互動難以描述

怎麼選?

場景推薦方法
前後端分離的單體應用OpenAPI Validation — 簡單夠用
微服務架構Pact CDC — 跨服務契約管理
已有 OpenAPI spec先用 Schema-Based,有需要再加 Pact
前端團隊和後端團隊完全獨立Pact CDC — 契約是溝通工具
只是想確保 API 格式不變Snapshot Testing — 最簡單

最簡方案:Response Snapshot

如果不想導入 Pact 或維護 OpenAPI,最低成本的做法:

// 用 Jest snapshot 記錄 API response 結構
it('GET /api/products response shape', async () => {
  const res = await request(app).get('/api/products').expect(200);
 
  // 只記錄結構,不記錄值
  const shape = Object.keys(res.body.data[0]).sort();
  expect(shape).toMatchInlineSnapshot(`
    [
      "category",
      "id",
      "name",
      "price",
      "status",
    ]
  `);
});

欄位名字一改,snapshot 就會告訴你。


CI/CD 整合

Pact 的 CI 流程

# .github/workflows/test.yml
 
# 前端 CI
frontend-contract:
  steps:
    - run: npm test -- --testPathPattern=pact
    - name: Upload Pact contract
      run: npx pact-broker publish pacts/ --consumer-app-version=${{ github.sha }}
 
# 後端 CI
backend-verify:
  needs: frontend-contract
  steps:
    - name: Verify Pact contract
      run: pytest tests/test_pact_verification.py

OpenAPI 的 CI 流程

# 更簡單
api-contract:
  steps:
    - run: npm test -- --testPathPattern=api-contract
    # 也可以加 lint
    - run: npx @redocly/cli lint openapi.yaml

常見問題

有了 Integration Test 還需要契約測試嗎?

需要。Integration Test 要啟動整個系統,跑得慢,而且只能測自己知道的場景。契約測試跑得快,而且能抓到「對方改了格式但你不知道」的問題。

Pact 和 OpenAPI Validation 可以一起用嗎?

可以,但通常沒必要。選一個主力就好。OpenAPI 做基本的格式驗證,有複雜跨服務需求再加 Pact。

小團隊(前後端都是同一個人)需要契約測試嗎?

通常不需要 Pact,但建議用 OpenAPI Validation 或 Snapshot Testing。成本低,但能防止你自己不小心改壞 API。

契約測試能取代 E2E 測試嗎?

不能。契約測試只驗格式,不驗業務流程。「下單後庫存有沒有扣」這種事要靠 Integration 或 E2E。

本系列文章

  1. 測試策略(一):測試金字塔
  2. 測試策略(二):Unit vs Integration
  3. 測試策略(三):E2E + 壓力測試
  4. CD 整合
  5. API 契約測試(本篇)
  6. 安全測試
  7. Smoke + 回歸測試
  8. 測試資料管理
  9. 視覺回歸測試
  10. AI 輔助測試
  11. ISO 29119)
  12. 測試相關憑證與學習路徑