私のチームで本番に到達したAPIの破壊変更は、どれもCIを通過していた。すべてだ。ユニットテストはグリーンだった。統合テストスイートも合格していた。デプロイが完了し、次にSlackのメッセージが届き始めた。

問題はテストをしていなかったわけではない。間違ったものをテストしていたのだ。ほとんどのCIパイプラインは、コードが動くかどうかを検証する。プロデューサーとコンシューマー間のAPI contractが依然として intact かどうかは検証しない。

なぜ既存のテストではAPIの破壊変更を検出できないのか

ユニットテストは内部の論理を検証する。モック化された依存関係を使ってハンドラを呼び出し、レスポンスをアサートする。統合テストはスタック全体を起動し、実際のエンドポイントにアクセスする。

どちらもプロデューサーをテストしている。現在の呼び出し方でAPIが正しく動作するかを検証する。先週のcontractに対して構築された外部コンシューマーが、依然として通信できるかどうかはテストしていない。

微妙な破壊変更こそが危険だ。リンターに文句を言われたから、user_iduserId に名前変更する。200レスポンスがフラットなオブジェクトではなくネストされたオブジェクトを返すように変更する。オプションだったクエリパラメータを必須にする。同じPR内で自分たちのテストも更新するので、すべて合格する。しかし、本番のすべてのクライアントが壊れる。

これが、コードをテストすることとcontractをテストすることの違いだ。

どのような変更が破壊的なAPI変更にあたるか

破壊的変更とは、正しく実装されたクライアントが失敗するようになるあらゆる変更のことだ。これはバグの話ではない。APIが約束したものの話だ。

最も一般的な破壊は、3つのカテゴリーに分類される。

  • 構造的変更: フィールドの削除や名前変更、型の変更、ネスト構造の変更
  • 振る舞いの変更: オプションパラメータを必須にする、ページネーションのデフォルト値を変更する、エラーレスポンスの形状を変更する
  • ライフサイクルの変更: エンドポイントの削除、URLパスの変更、警告なしのバージョン廃止

一部は明らかだ。他のものは、APIをコンシューマーの視点から見たときにのみ明らかになる。ほとんどのチームはそうしていない。

レイヤー1: OpenAPI diffing で構造的な破壊変更を検出する

OpenAPI specは、APIの形状を記述する。specをcontractとして扱えば、以前のバージョンとdiffを取り、マージ前に破壊的変更をフラグ立てできる。

oasdiff のようなツールは、2つのOpenAPIドキュメントを比較し、変更をbreaking、dangerous、safeに分類する。破壊的変更とは、レスポンスフィールドの削除や、パラメータをオプションから必須に変更するようなものだ。

GitHub Actionsワークフローでの実装例は以下の通りだ。

# .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が構造的な破壊的変更を導入した場合にビルドが失敗する。高速で決定論的であり、統合テストが見逃す名前変更や削除を検出する。

ただし、注意点がある。OpenAPI diffingはスキーマしか見ない。型を同じままフィールドの意味を変更した場合は検出できない。booleanの active フィールドが「アカウント有効」ではなく「メール認証済み」を意味するようになっても、diffには現れない。型は変わっていない。contractは変わっている。

レイヤー2: Pact でコンシューマーcontractを検証する

コンシューマー主導のcontract testingはモデルをひっくり返す。APIプロバイダーが自らの正しさを主張するのではなく、コンシューマーが自分が必要とするものを定義する。これらの期待がcontractとなり、プロバイダーはCIでそれを満たさなければならない。

仕組みはこうだ。フロントエンドチームが「GET /users/123 を呼び出したとき、ステータス200で本文に user_id が文字列として含まれることを期待する」というテストを書く。Pactはこのインタラクションを記録し、contractとして保存する。

プロバイダー側では、バックエンドのCIがすべてのコンシューマーcontractをダウンロードし、現在のコードに対して再生する。もしPRが user_id を削除したり数値に変更したりすれば、プロバイダーの検証は失敗する。自分たちのテストが通っていてもだ。

Pact JSでの最小限のコンシューマーテストは以下の通りだ。

// 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でのプロバイダー検証は以下の通りだ。

# .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

これにより、スキーマdiffingでは検出できない意味的な破壊変更を捉えられる。コンシューマーが特定の値の形式やエラーメッセージに依存している場合、Pactはそれをフラグ立てする。

ほとんどのチームが無視するトレードオフ

どちらのアプローチもタダではない。

OpenAPI diffingには正確なspecのメンテナンスが必要だ。specがコードアノテーションから生成される場合は自動的に最新の状態が保たれる。手書きの場合は乖離し、diffが無意味になる。生成されたspecの方がこのワークフローには適している。

Pactには組織的な規律が必要だ。コンシューマーがcontractテストを書く必要がある。contractを保存しバージョン管理するブローカーが必要だ。コンシューマーの期待が間違っているときは、誰かが変更を交渉しなければならない。これは摩擦を生む。それが目的だ。この摩擦が、静かな破壊を防ぐ。

両方を実行するのが理想だが、常に実用的とは限らない。重要なコンシューマーが1〜2つしかない場合、Pactはすぐに元を取る。匿名のAPIコンシューマーが数十個ある場合、OpenAPI diffingがより良い出発点だ。

もう一つ: どちらのツールもパフォーマンスの回帰や認証の変更は検出しない。以前は不要だった場所にOAuthを要求する新しいエンドポイントは、破壊的変更だ。認証スキーマが別途定義されていた場合、diffツールはそれをフラグ立てしないかもしれない。隙間に注意を払い続けよう。

よくある質問

破壊的なAPI変更とは何ですか?

破壊的なAPI変更とは、既存の正しく実装されたクライアントを失敗させるあらゆるAPIへの変更のことだ。これには、フィールドの削除、型の変更、オプションパラメータを必須にすること、既存エンドポイントのレスポンスステータスコードの変更などが含まれる。

API contract testing と統合テストの違いは何ですか?

統合テストは、システム全体が正しく動作するかを検証する。contractテストは、APIの公開インターフェースがコンシューマーの期待と一致するかを検証する。システムの両側が同じコミットで更新されていれば、統合テストはcontractが破壊されていても合格する可能性がある。

OpenAPI diffing は手書きのspecがなくても使えますか?

はい。oasdiff のようなツールは、SpringDoc、FastAPI、drf-spectacularなどのライブラリを使ってコードアノテーションから生成されたものも含め、あらゆるOpenAPIドキュメントで動作する。生成されたspecは実装と乖離しないため、多くの場合より信頼できる。

コンシューマー主導のcontractを使うのにPactブローカーは必要ですか?

数サービス以上のチームでは、必要だ。ブローカーはcontractを保存し、バージョンを追跡し、提案された変更の影響を受けるコンシューマーを表示する。これがないと、contractファイルを手動でやり取りすることになり、すぐに破綻する。

GraphQLの場合はどうですか?

GraphQLには異なる破壊的変更のセマンティクスがある。フィールドの削除は破壊的だが、追加は安全だ。GraphQL Inspector のようなツールは、OpenAPIツールと同様のスキーマdiffingを提供する。PactもGraphQLのインタラクションをサポートしている。

スキーマdiffから始めよう

OpenAPI diffingから始める。これは最も低い工数で、最も一般的な破壊を検出できる。今日、specが不完全でもCIに追加しよう。不完全なチェックも、チェックがないよりはるかにマシだ。

レビューで、誰かの週末を台無しにするはずだった破壊を捉えたとき、初めてこの重要性がわかるだろう。