我團隊裡每一個進到生產環境的 API 破壞,都通過了 CI。全部。單元測試是綠燈。整合測試也過了。部署出去後,Slack 訊息才開始跳。

問題不在於我們沒測試,而是測錯了東西。大多數 CI pipeline 只驗證程式碼能不能跑,卻沒驗證 producer 和 consumer 之間的 API contract 是否仍然完整。

為什麼你現有的測試抓不到 API 破壞

單元測試演練你的內部邏輯。它們用模擬的相依性呼叫你的 handler,然後對回應做斷言。整合測試則啟動整個技術堆疊,並命中真實的端點。

這兩種測試都是在測 producer。它們驗證的是,當你以目前的方式呼叫 API 時,它的行為是否正確。它們不會測試一個以外部 consumer 根據上週的 contract 建構出來的系統,是否還能與你通訊。

細微的破壞才是最危險的。你把某個欄位從 user_id 改名成 userId,因為 linter 在抱怨。你把一個 200 回應從回傳平坦物件改成巢狀物件。你把一個原本選用的 query parameter 改成必填。你自己的測試在同一個 PR 裡更新了,所以全部通過。但生產環境上的每一個客戶端都炸了。

這就是測試程式碼和測試 contracts 之間的差別。

什麼算是破壞性的 API 變更

破壞性變更是指任何導致正確實作的客戶端失敗的修改。這跟 bug 無關。這關乎的是你的 API 曾經做出的承諾。

最常見的破壞可以分為三大類:

  • 結構變更: 移除或重新命名欄位、變更型別、調整巢狀結構
  • 行為變更: 把選用參數改成必填、變更分頁預設值、修改錯誤回應的格式
  • 生命週期變更: 移除端點、變更 URL 路徑、未經預警就棄用版本

其中有些很明顯。但另一些只有當你從 consumer 的視角來看 API 時才會顯而易見。而大多數團隊並不是這樣看的。

第一層:用 OpenAPI diffing 抓結構破壞

OpenAPI spec 描述了你 API 的形狀。如果你把 spec 當作 contract,就可以在合併前將它與前一個版本做 diff,標記出破壞性變更。

oasdiff 這樣的工具會比較兩份 OpenAPI 文件,並將變更分類為 breaking、dangerous 或 safe。破壞性變更像是移除回應欄位,或是把參數從選用改成必填。

以下是它在 GitHub Actions workflow 中的樣子:

# .github/workflows/api-contract.yml
name: API Contract Check

on:
  pull_request:
    paths:
      - 'openapi.yaml'

jobs:
  diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Download oasdiff
        run: |
          curl -L https://github.com/Tufin/oasdiff/releases/latest/download/oasdiff_linux_amd64.tar.gz | tar xz
          sudo mv oasdiff /usr/local/bin/

      - name: Check for breaking changes
        run: |
          oasdiff breaking \
            --base origin/main:openapi.yaml \
            --revision openapi.yaml \
            --fail-on WARN

如果 PR 引入了結構性的破壞性變更,這會讓建置失敗。它快速、deterministic,而且能抓到整合測試漏掉的重新命名和移除。

但這有個陷阱。OpenAPI diffing 只看得到 schema。它無法判斷你是否在保持型別不變的情況下改變了某個欄位的語意。一個布林值的 active 欄位,如果從原本的「帳號已啟用」變成「電子郵件已驗證」,並不會出現在 diff 中。型別沒變,但 contract 變了。

第二層:用 Pact 驗證 consumer contracts

Consumer-driven contract testing 翻轉了這個模式。與其讓 API provider 自己宣稱正確性,不如讓 consumer 定義它們需要什麼。這些預期會變成 contracts,provider 必須在 CI 中滿足它們。

運作方式如下。你的前端團隊寫了一個測試,內容是:「當我呼叫 GET /users/123 時,我預期會收到 200,而且回應主體中包含字串型別的 user_id。」Pact 會記錄這個互動並儲存 contract。

在 provider 端,你的後端 CI 會拉下所有 consumer contracts,並對目前的程式碼重播它們。如果某個 PR 移除了 user_id 或把它改成數字,provider verification 就會失敗。即便你自己的測試通過也一樣。

一個使用 Pact JS 的最小 consumer 測試:

// consumer.spec.js
const { PactV3 } = require('@pact-foundation/pact');
const { expect } = require('chai');

const provider = new PactV3({
  consumer: 'web-app',
  provider: 'user-service',
  dir: './pacts',
});

describe('GET /users/:id', () => {
  it('returns the user', async () => {
    await provider
      .given('user exists')
      .uponReceiving('a request for user 123')
      .withRequest({
        method: 'GET',
        path: '/users/123',
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          user_id: '123',
          email: 'alice@example.com',
        },
      });

    await provider.executeTest(async (mockserver) => {
      const user = await fetchUser(mockserver.url, '123');
      expect(user.user_id).to.equal('123');
    });
  });
});

CI 中的 provider verification:

# .github/workflows/verify-contracts.yml
name: Verify Consumer Contracts

on: [pull_request]

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Verify pacts
        run: |
          docker run --rm \
            -v $(pwd)/pacts:/pacts \
            pactfoundation/pact-cli \
            verify-provider \
            --provider-app-version ${{ github.sha }} \
            --pact-broker-base-url https://your-pact-broker.io \
            --provider user-service

這能抓到 schema diffing 看不見的語意破壞。如果某個 consumer 依賴特定的值格式或錯誤訊息,Pact 會標記出來。

大多數團隊忽略的權衡

這兩種方法都不是免費的。

OpenAPI diffing 需要你維護一份準確的 spec。如果你的 spec 是從程式碼註解產生的,它會自動保持最新。如果是手寫的,它會漂移,diff 就變得沒有意義。對這個 workflow 來說,自動產生的 spec 比較好。

Pact 需要組織紀律。Consumer 必須撰寫 contract tests。你需要一個 broker 來儲存和版本控制 contracts。當 consumer 的預期有誤時,必須有人出面協商變更。這會產生摩擦。但這正是重點。摩擦能防止無聲的破壞。

同時執行兩者是理想狀態,但不一定實際。如果你有一兩個關鍵的 consumer,Pact 很快就能值回票價。如果你有數十個匿名的 API consumer,OpenAPI diffing 是更好的起點。

還有一件事:這兩個工具都抓不到效能迴歸或認證變更。一個以前不需要 OAuth 的新端點現在需要 OAuth,這也是破壞性變更。如果 auth schema 早已在其他地方定義,你的 diff 工具可能不會標記它。請留意這些缺口。

常見問題

什麼是破壞性 API 變更?

破壞性 API 變更是指任何導致現有、正確實作的客戶端失敗的 API 修改。這包括移除欄位、變更型別、把選用參數改為必填,或變更現有端點的回應狀態碼。

API contract testing 和整合測試有什麼不同?

整合測試驗證整個系統是否正常運作。Contract tests 驗證 API 的公開 interface 是否符合 consumer 的預期。如果系統兩端在同一個 commit 中一起更新,整合測試即使 contract 已經破壞也可能通過。

我可以不用手寫 spec 就使用 OpenAPI diffing 嗎?

可以。像 oasdiff 這樣的工具可以處理任何 OpenAPI 文件,包括用 SpringDoc、FastAPI 或 drf-spectacular 等函式庫從程式碼註解自動產生的文件。自動產生的 spec 通常更可靠,因為它們不會與實作漂移。

使用 consumer-driven contracts 需要 Pact broker 嗎?

對於擁有超過兩三個服務的團隊來說,是的。Broker 儲存 contracts、追蹤版本,並顯示哪些 consumer 會受到某項提議變更的影響。沒有它的話,你得手動傳遞 contract 檔案,這很快就會崩潰。

那 GraphQL 呢?

GraphQL 有不同的破壞性變更語意。移除欄位是 breaking 的,但新增欄位是 safe 的。像 GraphQL Inspector 這樣的工具提供類似 OpenAPI 工具的 schema diffing。Pact 也支援 GraphQL interactions。

從 schema diff 開始

從 OpenAPI diffing 開始。這是花費最少心力、卻能抓到最常見破壞的方法。今天就把它加進你的 CI,即使你的 spec 還不完美。不完美的檢查總比沒有檢查好。

一旦你在 review 中抓到了一個差點毀掉某人週末的破壞,你就會明白這為什麼重要。