コントラクトテスト:壊れたAPIの約束を本番環境に届く前に検出する

ユーザーサービスの変更をデプロイした。全テストがパスした。パイプラインもグリーン。5分後、通知サービスが本番環境でエラーを吐き出し始める。ユーザーには真っ白な画面が表示される。原因は?あなたはAPIレスポンスから、自分のサービスでは使っていないが通知サービスが依存していたフィールドを削除してしまったのだ。

統合テストではこれを検出できなかった。両チームとも自分たちのテストを実行し、すべてパスしていた。問題が表面化したのは、サービス同士が実際に本番環境で通信したときだけだった。このギャップを埋めるのがコントラクトテストである。

統合テストが見逃す本当の問題

統合テストは、サービスが接続してデータを交換できることを検証する。しかし、片方が変更されたときにサービス間の合意が有効であり続けることは保証しない。2つのチームがそれぞれ完全にグリーンな統合テストを持っていても、次のデプロイで互いのシステムを壊す可能性がある。

こう考えてみよう。チームAがユーザーサービスを運用している。チームBはユーザーデータを消費する通知サービスを運用している。チームAは、自社のコードベース内で使われていないように見えるフィールドを削除してAPIレスポンスを整理することにした。彼らの統合テストは、自分たちのサービスに対してテストするためパスする。チームBの統合テストも、古いAPIのモックやスナップショットに対してテストするためパスする。壊れたコントラクトが明らかになるのは、両方のサービスがステージングや本番環境で一緒に実行されたときだけだ。

コントラクトテストが実際に行うこと

コントラクトテストは、サービス間の暗黙の合意を明示的かつチェック可能にする。2つのサービスがAPIを通じて通信するたびに、何が送信され何が受信されるかについての取り決めがある。コントラクトテストはその取り決めを自動化されたチェックに変換する。

この概念は2つの役割で機能する。

次のシーケンス図は、プロバイダーがコントラクトを公開し、コンシューマーがデプロイ前にそれを検証する流れを示している。

sequenceDiagram participant Provider as User Service (Provider) participant Contract as Contract Repository participant Consumer as Notification Service (Consumer) Provider->>Contract: Publish API contract Consumer->>Contract: Fetch contract Consumer->>Consumer: Verify response matches contract alt Contract valid Consumer->>Consumer: Deploy safely else Contract broken Consumer->>Consumer: Fail build, alert team end
  • プロバイダー:APIを提供するサービス
  • コンシューマー:APIを呼び出すサービス

プロバイダーは提供するものを宣言する。コンシューマーは実際に必要なものを宣言する。両者が合意している限り、コントラクトテストはパスする。何かが変更されると、その変更が本番環境に届く前にテストが失敗する。

ほとんどのチームはコンシューマー駆動のアプローチを採用している。コンシューマーはコントラクトファイルに期待値を定義する。プロバイダーは、自身のAPIがすべてのコンシューマーからのすべてのコントラクトをまだ満たしているかを確認する。プロバイダーの変更がいずれかのコントラクトに違反する場合、プロバイダーチームはすぐにそれを知る。コンシューマーチームと話し合い、変更を調整するか、既存のコンシューマーを壊さずにAPIをバージョンアップできる。

コントラクトテストが統合テストより速い理由

最大の実用的な利点は速度と独立性だ。コントラクトテストは他のサービスを実行する必要がない。実際のデータベースや完全なステージング環境も不要だ。事前に定義されたテストデータでプロバイダーを実行し、レスポンスがコントラクトと一致するかを確認するだけだ。

コントラクトテストは数秒で完了する。複数のサービスやデータベースを起動する統合テストは数分かかる。CIパイプラインでは、この違いが重要だ。コントラクトテストを早期に実行し、高価な統合スイートを待たずに迅速に失敗できる。

コントラクトテストは、制御できない外部依存関係にも役立つ。アプリケーションがサードパーティのAPIを呼び出す場合、外部APIが期待する形式をまだ返すかをチェックするコントラクトテストを書ける。サードパーティがAPIを変更すると、コントラクトテストが失敗し、ユーザーに影響が出る前にそれを知ることができる。

コントラクトテストがカバーしないこと

コントラクトテストはフォーマットと構造のみをチェックする。正しいフィールドが正しい型で存在することを検証する。ビジネス的な観点からデータが正しいかどうかはチェックしない。ネットワークの安定性、本番環境での認証、負荷時の応答時間もテストしない。

これらの懸念には、依然として統合テストが必要だ。コントラクトテストと統合テストは補完関係にあり、代替ではない。コントラクトテストは構造的な不一致を早期に検出する。統合テストは実行時やデータの問題を後で検出する。

パイプラインにおけるコントラクトテストの位置づけ

コントラクトテストはユニットテストの後、統合テストの前に配置する。この順序は理にかなっている。コントラクトテストは統合テストより速く、これから一緒にテストしようとしているサービスがまだ互換性があるという追加の確信を与えるからだ。コントラクトテストが失敗した場合、おそらく失敗するであろう高価な統合テストを実行する意味はない。

典型的なパイプラインの順序は次のとおりだ。

  1. ユニットテスト
  2. コントラクトテスト
  3. 統合テスト
  4. エンドツーエンドテスト(必要な場合)

どこから始めるか

すべてのサービスに一度にコントラクトテストを追加しようとしてはならない。最も頻繁に変更され、他のサービスとの通信で最も問題を引き起こすサービスから始める。これらは、チームが互いの成果物を頻繁に壊す摩擦点だ。

次のシグナルを探そう。

  • あるチームの変更が別のチームの機能を頻繁に壊すサービス
  • 複数のコンシューマーが依存するAPI
  • レスポンス形式を定期的に変更するサービス
  • 過去に予期せず変更された外部API

まずはこれらに集中する。コントラクトテストが実行され実際の問題を捉えるようになったら、徐々に他のサービスに拡大する。

コントラクトテストを追加する前の実用的チェックリスト

  • クロスチームの破損を最も引き起こす上位3つのサービス間ペアを特定する
  • コンシューマー駆動かプロバイダー駆動のコントラクトかを決定する
  • スタックに合ったコントラクトテストツールを選ぶ(Pact、Spring Cloud Contractなど)
  • 1つのプロバイダーと1つのコンシューマーから始める
  • 統合テストの前にCIでコントラクトテストを実行する
  • コントラクトが壊れたときにチームに通知するプロセスを設定する

まとめ

コントラクトテストは、APIの不一致を変更が行われた瞬間に、デプロイ後ではなく検出する。高速に実行でき、完全な環境を必要とせず、壊れた約束が本番環境に届く前にチームに早期警告を与える。最も痛みを伴うサービス境界から始め、コントラクトを自動化し、パイプラインに合意が破られたことを教えてもらおう。ユーザーはその違いに気づくことはない。そしてそれがまさにポイントなのだ。