結論先講
契約測試不是測 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.successPact 的優缺點
| 優點 | 缺點 |
|---|---|
| 前後端獨立測試,不需要對方在線 | 學習曲線較陡 |
| 精確到欄位層級的驗證 | 需要 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.pyOpenAPI 的 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。