
Architecture Journal
契約測試:AI 時代自動化測試與驗收(五)如何保護 Consumer 與 Provider 的共同承諾
系列定位:前一篇從資料庫、交易、序列化、外部 HTTP 和 outbox 拆解 integration test。這一篇再往跨服務邊界走一步:當 Consumer 和 Provider 分開開發、分開部署時,怎麼確認雙方對同一個 API 或事件仍然有相同理解?
unit test 綠燈,代表單一規則還成立。integration test 綠燈,代表某個服務和資料庫、HTTP client 或訊息系統接得起來。
如果 integration test 只接 fake 或 stub,它可能看不到 Consumer 和 Provider 之間的欄位不相容;如果它真的讓 Consumer 呼叫正在運作的 Provider,這個問題通常就會被抓到。兩種測試都合理,差別在於測試環境的成本。
契約測試要處理的是:無法便宜地讓兩個服務一起啟動,但仍想提早驗證跨服務邊界相容性的情況。它把互動中真正重要的承諾寫成可執行的檢查,讓 Consumer 和 Provider 可以分開驗證。
契約測試保護的是邊界上的共同承諾
先把這幾種測試的責任分開:
| 測試 | 主要問題 | 保護的風險 |
|---|---|---|
| Unit test | 單一行為規則對不對? | 計算、狀態轉換、不變量 |
| Integration test | 元件接起來後能不能運作? | 資料庫、序列化、transaction、runtime wiring |
| Contract test | Consumer 和 Provider 的約定是否相容? | API 欄位、事件格式、狀態碼、錯誤語意 |
| End-to-end test | 完整使用者流程能不能走完? | 多服務拓撲、部署設定、關鍵旅程 |
契約測試的單位不是某個 class,也不是整個產品,而是一次跨服務互動。這個互動可能是:
- Consumer 呼叫
GET /orders/{id},期待收到哪些欄位與狀態碼。 - Provider 發出
OrderCreated事件,Consumer 依賴哪些欄位與訊息標頭。 - Consumer 收到付款失敗事件後,必須把訂單轉成哪個狀態。
契約只需要描述互動中真正被依賴的部分,不需要把 Provider 的所有內部行為或整份 domain model 都搬進去。Spring Cloud Contract 的文件也特別提醒,契約測試是在測應用程式之間的契約,不是用來描述完整業務功能。[1]
這裡的「驗收」不是使用者驗收。契約測試只驗證服務邊界的互動相容性;完整的業務流程和使用者旅程,仍然需要 acceptance test 或 E2E test。
當跨服務 integration test 太貴時,契約測試補什麼
先修正一個容易誤導的說法:integration test 沒有「天然不夠」。如果 Consumer 的 integration test 真的呼叫正在運作的 Provider,Consumer 期待 total_amount、Provider 卻回傳 total,這個測試就會失敗。這時再加契約測試,可能只是重複覆蓋。
契約測試處理的是另一個問題:兩個服務不能或不值得每次一起啟動。
例如:
- Consumer 端測試讓 client 呼叫 fake Provider,fake 回傳
total_amount。 - Provider 的測試啟動自己的 API 和資料庫,但只確認自己能產生資料。
- 兩邊的測試都綠燈,因為 Consumer 看到的是 fake,Provider 沒有看到 Consumer 的期待。
當兩個服務真的一起啟動時,完整的跨服務 integration test 可以抓到這個欄位不一致;問題是它可能需要準備兩套服務、資料庫、broker 和部署設定,回饋慢,失敗也難定位。
在 CDC 流程中,Consumer 先寫出自己依賴的互動,Provider 用同一份契約驗證實作,Consumer 再使用由契約產生的 stub。它不會比真實跨服務 integration test 更接近 production,也不會驗證更多業務邏輯;它只是用較低的環境成本,提早驗證服務邊界是否相容。
Contract 不是 API 文件的同義詞
API 文件通常要服務很多讀者:前端、後端、測試、維運、外部整合者。契約測試則只需要回答一個更窄的問題:這個 Consumer 依賴的互動,Provider 是否仍然提供?
一份可用的契約通常包含:
- request 或 event 的方法、路徑、topic、header 與必要欄位。
- response 或 event 的狀態碼、欄位型別、格式與必要語意。
- 哪些值是固定的,哪些值只需要符合格式或範圍。
- Consumer 真的使用哪些欄位,以及 Provider 必須保留哪些相容性。
它通常不應該包含:
- Provider 內部用了哪個 service 或 repository method。
- 完整的資料庫 schema 與不會跨服務流動的欄位。
- 所有可能的業務情境,或一整套 E2E 流程。
契約過寬,測試抓不到真正的破壞;契約過窄,Provider 每次內部重構都可能被不必要地綁住。這裡的判斷和上一篇談 mock 的原則一樣:先問這個欄位或互動是不是跨服務的承諾,再決定要不要寫進契約。
Consumer-driven contract 到底是什麼
Consumer-driven contract(CDC)不是「Consumer 寫完契約,Provider 無條件照做」。它是一種協作流程:Consumer 先把自己真正依賴的互動寫出來,Provider 再驗證自己的實作是否滿足這些需求。
Consumer 之所以適合先提出契約,是因為它最清楚自己實際使用了哪些欄位。Provider 可能提供一個很大的 response,但 Consumer 只依賴其中五個欄位;契約應該先保護這五個欄位,而不是把整份 response 的每個欄位都鎖死。
不過,CDC 不是唯一模式:
| 模式 | 誰定義契約 | 適合情境 |
|---|---|---|
| Consumer-driven | Consumer 提出需求,Provider 驗證 | Consumer 數量有限,雙方能協作 |
| Provider-owned contract | Provider 定義公開介面,Consumer 使用 | 外部 Consumer 多,或 Provider 無法逐一協作 |
| Shared schema | 團隊共同維護 schema 與相容性規則 | 事件平台、Schema Registry、跨語言整合 |
Spring Cloud Contract 同時支援 Consumer-driven contracts 與由 Provider 定義的 contracts。真正重要的不是把某一種模式叫成唯一正解,而是契約的來源、審查責任與驗證流程要清楚。[2]
Pact 也採用 consumer-driven、以實際互動案例產生契約的方式。這和只驗證 Provider 是否符合一份 schema 或 API 文件不同:後者不一定能證明目前的 Consumer 真的能正確使用它。[3]
用 OrderCreated 看一次完整契約
沿用前一篇的下單案例。Order Service 建立訂單後,發布 OrderCreated 事件;Payment Service 會消費這個事件,建立或更新付款流程。
以下假設這個專案約定金額以 decimal string 傳遞,時間一律使用 UTC;實際專案應以自己的 API 與事件規格為準。先不要從 Java class 或 Kafka listener 倒推契約,先寫兩個服務真正同意的互動:
| 欄位 | Provider 承諾 | Consumer 使用方式 |
|---|---|---|
| topic | 固定送到 orders | 綁定消費來源 |
event-type(header) | 固定為 OrderCreated | 決定事件路由 |
event_id | UUID 格式;全域唯一由事件生成規則保證 | 去重與追蹤 |
order_id | 非空字串,代表訂單 | 建立付款關聯 |
total_amount | 使用約定的金額格式 | 計算付款金額 |
occurred_at | RFC 3339 UTC datetime | 記錄處理時間線 |
這張表不是完整的 Order domain model。Payment Service 不需要知道 Order Service 的 repository、資料表或內部狀態機;它只需要知道收到事件後可以安全解析哪些欄位。
可以把契約寫成接近協議的形式:
topic: orders
headers:
event-type: OrderCreated
content-type: application/json
body:
event_id: UUID
order_id: non-empty string
total_amount: decimal string
occurred_at: RFC 3339 UTC datetime
這段不是特定工具的完整 DSL,而是先把「跨服務承諾」寫清楚。真正落地時,JSON Schema 可以表達 body 的結構與型別;topic、header、trigger 和 Consumer/Provider 的互動,則要交給 Spring Cloud Contract、Pact 或團隊既有的契約工具表達。
Provider 要驗證自己沒有違約
Provider 端的測試問題不是「這個 method 有沒有被呼叫」,而是「對外輸出的互動是否符合契約」。以 OrderCreated 為例,Provider 測試至少要確認:
- 在契約指定的測試情境被觸發時,確實發出事件。
- topic、event type、header 和 body 欄位符合契約。
event_id、order_id、total_amount的格式正確。- 契約要求的欄位沒有因為重構或 serializer 修改而消失。
這和前一篇的 integration test 有重疊,但責任不同。integration test 可以用真實 broker 驗證 outbox、publisher、serializer、ack 和 retry;contract test 則集中確認「送出去的事件長什麼樣子」。不需要每一個 Consumer 都被同時啟動,Provider 也可以先知道自己是否破壞了已發布的契約。
以 Spring Cloud Contract 為例,Provider 可以從契約定義產生 verifier tests,讓建置流程檢查 HTTP 或 messaging output 是否符合契約。Messaging contract 也可以透過 message verifier 連接實際的 messaging integration。[1]
Consumer 端如何使用契約資料
Consumer 端可以使用契約產生的 stub 或測試訊息,測試自己的 client 或 listener 能不能處理 Provider 承諾的輸入。這是搭配契約資料的 Consumer 行為測試,不代表契約本身會驗證完整業務流程。
Payment Service 的 Consumer 行為測試可以做這幾件事:
- 載入一份符合
OrderCreated契約的事件。 - 讓 Payment Service 的 listener 處理它。
- 搭配 Consumer 自己的行為測試,驗證付款記錄建立或狀態轉換符合規則。
- 確認不需要啟動真正的 Order Service。
這裡要分清楚兩個責任。契約提供的是「輸入會符合什麼格式」;Payment Service 自己的 unit 或 integration test,才負責驗證收到輸入後如何計算、寫入資料庫和處理錯誤。契約資料可以餵給 listener,但不應該因此取代完整的業務行為測試。
Spring Cloud Contract 的 Stub Runner 可以下載或載入 Provider 產生的 stubs;HTTP 情境常見的是啟動 stub server,messaging 情境則可以透過 messaging route 或 trigger 產生測試訊息。契約本身也要描述訊息來源、destination、body、header,以及觸發訊息的方式。實際接法取決於使用的 messaging framework,不能把 HTTP stub server 的做法直接套到 Kafka。[1]
契約測試不會自動保證語意正確
契約測試最容易被誤解成「有契約就安全」。它能抓到格式與約定被破壞,卻不會自動判斷欄位名稱背後的業務意義。
例如 total_amount 從「含稅金額」改成「未稅金額」,型別和欄位名稱都沒有變,契約測試可能完全通過,但 Payment Service 算出的金額已經錯了。
因此,契約需要同時寫出幾種資訊:
- 型別與格式:字串、數字、UUID、時間格式。
- 必填與選填:Consumer 是否能接受欄位不存在。
- 相容性:新增欄位是否允許,移除或改名是否為 breaking change。
- 語意:金額是否含稅、時間使用 UTC 還是本地時區、狀態值代表什麼。
其中語意不能只靠 matcher 或 schema 解決。它需要規格討論、範例、review,必要時也要把關鍵不變量寫進 Consumer 的行為測試。
版本演進:新增欄位不一定安全
最常見的說法是「只要向後相容,新增欄位就安全」。這句話少了一個前提:Consumer 是否會拒絕未知欄位,或是否把整個 response 當成完全相等的物件比較。
可以用這個角度檢查變更:
| 變更 | 常見風險 | 處理方向 |
|---|---|---|
| 新增 optional 欄位 | 舊 Consumer 不接受未知欄位 | 先確認 parser 與序列化設定 |
| 移除欄位 | Consumer 仍在使用 | 先查契約與使用者,再分階段淘汰 |
| 改變型別 | 解析直接失敗 | 發新版本或提供相容格式 |
| 改變 enum/狀態語意 | 型別沒變,決策卻變了 | 視為需要協作的 breaking change |
| 改變金額、時間或單位 | 數值可解析但結果錯 | 把單位與語意寫進契約與案例 |
契約測試的價值不只是在變更後讓 CI 變紅,也是在變更前讓團隊看見「誰依賴這個欄位」。Spring Cloud Contract 的 stubs-per-consumer 模式,就是把不同 Consumer 的契約分開管理,讓 Provider 看見每個 Consumer 實際使用的部分。[1]
Contract test、schema test、integration test 怎麼分
這些名稱常常被混在一起,但它們回答的問題不一樣:
| 測試 | 問題 | 是否需要完整 Provider |
|---|---|---|
| Schema validation | payload 是否符合結構與型別? | 不一定 |
| Contract test | 這個 Consumer 和 Provider 的互動是否相容? | 通常不需要同時啟動 |
| Integration test | 實際 client、broker、serializer 和依賴能否運作? | 依案例決定 |
| Acceptance/E2E test | 完整使用者旅程或業務流程是否成立? | 通常需要多個服務 |
這些測試不是一條嚴格的 unit → contract → integration → E2E 階梯。Unit test 是依賴隔離的行為測試;contract test 是跨服務邊界的相容性測試;integration test 是實際元件、基礎設施與 runtime wiring 的整合測試;acceptance/E2E test 則是完整旅程的測試。它們可以在同一條 CI pipeline 中依序執行,但責任和依賴範圍不同,也不必互相取代。
以這個系列的敘事順序來說,契約測試放在 integration test 之後、E2E 之前是合適的:上一篇先說明單一服務如何和資料庫、HTTP 或 outbox 接起來,這一篇再處理服務之間如何保持共同理解,下一篇才討論完整拓撲與使用者旅程。但這是由問題範圍安排出的閱讀順序,不代表測試金字塔裡存在唯一的線性層級。
Schema validation 可以是契約測試的一部分,但單獨通過 schema 不代表 Consumer 的行為正確。反過來,Consumer contract test 也不應該偷偷變成完整 integration test,否則每個 Consumer 都要準備同一套資料庫、broker 和部署環境,契約測試很快就失去快速回饋的優勢。
不是每個服務邊界都需要契約測試
契約測試不是測試金字塔裡必須補上的一層,而是用維護成本換取更早跨服務回饋的選擇。是否採用,應該看服務邊界的風險,以及現有測試能不能便宜地發現相同問題:
| 情境 | 建議 |
|---|---|
| Consumer 和 Provider 分開部署,變更節奏也不同 | 值得考慮契約測試 |
| Consumer 很多,E2E 昂貴又難定位 | 值得考慮契約測試 |
| Consumer mock 經常和真實 Provider 漂移 | 值得考慮契約測試 |
| 同一個 repository、一起部署,真實 integration test 很便宜 | 優先維護 integration test |
| 介面穩定且只需要檢查 payload 結構 | Schema validation 可能已經足夠 |
如果 integration test 已經能低成本呼叫真實 Provider,契約測試可能只是重複覆蓋。反過來,如果每次驗證跨服務相容性都要啟動完整環境,契約測試的價值就不只是多一個測試種類,而是把回饋提前,並讓失敗更容易歸因到某個 Consumer 或 Provider。
實務上不需要一次替所有服務邊界導入。可以先挑一個最常變更、Consumer 最多,或一旦不相容就會造成高成本事故的互動,寫一份最小契約,觀察它是否真的減少 mock 漂移與晚期整合失敗。如果團隊沒有契約發布、驗證和退場的責任人,先不要急著引入工具。
最常見的五個誤區
-
把所有 API 文件欄位都寫成契約。 這會讓契約變成 Provider 的完整 DTO 快照,任何不影響 Consumer 的欄位調整都可能造成不必要的失敗。
-
只測 Provider,不測 Consumer。 Provider 通過只代表它能產生符合契約的輸出,Consumer 仍可能解析錯誤或忽略必要的狀態轉換。
-
只測 Consumer 的 mock,不讓 Provider 驗證同一份契約。 這樣只是把 Consumer 的猜測保存下來,沒有形成雙方共同的檢查。
-
把契約測試寫成 E2E。 契約測試的目的,是讓雙方可以獨立驗證;如果每次都把所有服務和真實 broker 啟動,測試成本就回到 integration 或 E2E 的問題。
-
把業務流程全部塞進契約。 契約應該保護跨服務互動,不是描述「建立訂單到付款完成」的所有分支。完整流程仍然要由 integration、E2E 或 acceptance test 負責。
AI 可以幫忙產生契約,但不能替團隊決定語意
AI 很適合協助整理既有 API、事件 payload 和 Consumer 使用位置:
- 從程式碼與測試找出 Consumer 實際讀取的欄位。
- 將既有案例整理成 HTTP 或 messaging contract 的初稿。
- 列出新增、移除、改名與型別變更可能影響的 Consumer。
- 產生 Provider verifier 與 Consumer stub test 的骨架。
但有三件事不能只讓 AI 從目前程式碼猜:
- 欄位的業務語意,例如金額是否含稅。
- 欄位缺少或新增時,舊 Consumer 應該怎麼做。
- 事件是允許重送、亂序,還是必須依序處理。
給 AI 的 prompt 應該先提供跨服務承諾,再要求它產生契約:
請為 Order Service 的 OrderCreated event 設計 consumer-driven contract。
Consumer:Payment Service
Consumer 實際使用:event_id、order_id、total_amount、occurred_at
必須保留:event-type header、JSON 欄位名稱、total_amount 的 decimal string 格式
相容性規則:可新增 optional 欄位,不可移除既有欄位,不可改變金額單位
不要包含:repository、database schema、private method、完整付款流程
這樣 AI 產生的是可 review 的契約草稿,不是把目前 DTO 原封不動複製成另一份規格。人仍然要確認契約是否真的代表跨團隊共同承諾。
放進 CI:Provider 和 Consumer 都要有回饋
以下流程以 CDC、由 Consumer 產生並發布契約為例。若採用 Provider-owned contract 或 shared schema,契約來源與發布流程會不同。
契約測試的 CI 流程至少要讓兩邊都能收到結果:
Consumer pull request
-> 產生或更新 Consumer contract
-> Consumer test 使用 Provider stub
-> 發布契約或測試資產
Provider pull request
-> 讀取已發布的 Consumer contracts
-> 執行 Provider verifier tests
-> 通過後才允許發布新版本
如果契約資產放在 artifact repository、Git repository 或 registry,版本、來源和 Consumer 名稱都要可追蹤。否則測試失敗時,團隊只知道「某個 stub 不相容」,卻不知道是哪個 Consumer、哪個版本或哪一次變更造成的。
也不要把每個 Consumer 的契約都當成永遠不能改的法律。契約反映的是目前的使用者與系統需求;當 Consumer 被淘汰、事件版本升級或業務語意改變時,契約也要有審查與退場流程。
結論
unit test 保護單一規則,integration test 保護元件接起來後的行為,contract test 則保護 Consumer 和 Provider 對同一個跨服務互動的共同理解。這些測試不是固定要全部存在的清單,而是針對不同風險做取捨。
契約測試最有價值的地方,不是讓測試種類再多一種,而是讓服務可以分開演進,卻不必等到完整部署後才知道彼此已經不相容。它也不是 integration 或 E2E 的替代品:資料庫、broker、retry 和部署拓撲仍然需要其他測試層負責。
因此,契約測試不是每個服務邊界都要加上的一層。只有當服務必須獨立部署,而真實跨服務 integration test 又慢又難定位時,才值得用額外維護成本換取更早的回饋。
真正困難的地方,是決定哪些欄位和語意值得成為共同承諾。AI 可以從程式碼和測試快速整理出候選契約,但不能替團隊決定一個欄位改名後,誰需要先知道、誰必須遷移、什麼時候可以刪除舊版本。
下一篇轉向 E2E 測試:當整個服務拓撲真的要一起跑時,哪些使用者旅程值得付出這個成本,又要怎麼避免 E2E 變成慢、脆弱、失敗後難以定位的大型測試。
參考資料
- [1] Spring Cloud Contract,〈Getting Started〉
- [2] Spring Cloud Contract,〈Documentation Overview〉
- [3] Pact,〈Introduction〉