簡介
當20個服務相互通訊時,如何確保更改服務A中的API不會破壞服務B? 合約測試解決了這個問題,而無需同時運行所有服務。
1. 問題:API 重大更改
Service A (Consumer) gọi Service B (Provider):
Service B thay đổi response:
Before: { "price": 999 } ← number
After: { "price": "999.00" } ← string!
→ Service A parse Price as number → crash!
→ Integration test phát hiện? Chỉ nếu chạy cả A + B cùng lúc
→ E2E test? Chậm, flaky, expensive
→ Contract test? ✅ Phát hiện sớm, CI tốc độ cao
2. 消費者驅動的合約測試
2.1 概念
Consumer (Order Service):
"Tôi expect response từ Product Service có format:
{ id: string, name: string, price: number }"
→ Ghi thành Pact (contract file)
Provider (Product Service):
"Để tôi verify rằng API của tôi satisfy contract này"
→ Run Pact verification against real API
Nếu fail → Provider biết thay đổi sẽ break Consumer
→ Fix API hoặc coordinate migration
2.2 流程
┌──────────────┐ 1. Generate ┌──────────────┐
│ Consumer │────Pact File───►│ Pact Broker │
│ (Order Svc) │ (contract) │ (central │
└──────────────┘ │ registry) │
└──────┬───────┘
│
2. Verify│
▼
┌──────────────┐
│ Provider │
│ (Product Svc)│
└──────────────┘
3. 協定實施
3.1 消費端(訂單服務)
// order-service/tests/pact/product.consumer.test.js
const { PactV3 } = require('@pact-foundation/pact');
describe('Product Service Contract', () => {
const provider = new PactV3({
consumer: 'OrderService',
provider: 'ProductService',
});
it('get product by id', async () => {
// Define expected interaction
provider
.given('product 123 exists')
.uponReceiving('a request for product 123')
.withRequest({
method: 'GET',
path: '/api/products/123',
})
.willRespondWith({
status: 200,
body: {
id: '123',
name: like('Laptop'),
price: like(999),
inStock: like(true),
},
});
// Execute test against Pact mock
await provider.executeTest(async (mockService) => {
const product = await fetchProduct(mockService.url, '123');
expect(product.id).toBe('123');
expect(typeof product.price).toBe('number');
});
});
});
3.2 提供者驗證(產品服務)
// product-service/tests/pact/provider.verify.test.js
const { Verifier } = require('@pact-foundation/pact');
describe('Pact Verification', () => {
it('validates OrderService contract', async () => {
const verifier = new Verifier({
providerBaseUrl: 'http://localhost:8080',
pactBrokerUrl: 'https://pact-broker.company.com',
provider: 'ProductService',
providerVersion: process.env.GIT_SHA,
publishVerificationResult: true,
stateHandlers: {
'product 123 exists': async () => {
await seedDB({ id: '123', name: 'Laptop', price: 999 });
},
},
});
await verifier.verifyProvider();
});
});
4. CI/CD 集成
# Consumer CI (Order Service)
consumer-contract-test:
steps:
- run: npm test -- --testPathPattern=pact
- run: npx pact-broker publish pacts/
--consumer-app-version=$GIT_SHA
--branch=$BRANCH
# Provider CI (Product Service)
provider-contract-verify:
steps:
- run: npm run pact:verify
# can-i-deploy check before deploymentt
- run: npx pact-broker can-i-deploy
--pacticipant=ProductService
--version=$GIT_SHA
--to-environment=production
4.1 可以部署嗎
Before deploying Product Service v2.1:
$ pact-broker can-i-deploy \
--pacticipant ProductService \
--version 2.1 \
--to production
✅ OrderService (1.5) → ProductService (2.1): VERIFIED
✅ CartService (2.0) → ProductService (2.1): VERIFIED
Result: Safe to deploy!
5. 模式演化最佳實踐
| 更改類型 | 打破? | 戰略 |
|---|---|---|
| 新增欄位 | 沒有 | 只需新增(消費者忽略未知) |
| 刪除欄位 | 是 | 棄用 → 驗證沒有消費者使用 → 刪除 |
| 重新命名欄位 | 是 | 新增新的→遷移消費者→刪除舊的 |
| 更改類型 | 是 | 新欄位→遷移→刪除舊欄位 |
| 新增端點 | 沒有 | 只需新增 |
| 刪除端點 | 是 | 合約測試擷取使用情況 |
總結
- 合約測試 = 驗證服務之間的 API 合約
- 消費者驅動:消費者定義期望,提供者驗證
- Pact:業界標準工具,支援多種語言
- Pact Broker:中央登錄 + can-i-deploy 安全檢查
- CI/CD:在每個 PR 上運行,如果合約被破壞則阻止部署
- 在整合測試之前捕獲重大變更**