APIの破壊的変更を止める:Pactによるコンシューマ駆動契約テスト(CDCT)実践ガイド

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

週末を台無しにした、ある金曜日の午後

3年前、ある「単純な」APIのクリーンアップ作業によって、私の週末は全て潰れてしまいました。当時、私のチームは高トラフィックなECプラットフォーム向けに約25個のマイクロサービスを管理していました。私はUser Serviceから、もう使われていない2つのフィールドを削除するタスクを担当しました。内部ドキュメントを確認し、ユニットテストを実行し、すべてがパス(グリーン)であることを確認しました。午後4時に本番環境へプッシュし、帰宅の準備を始めました。

しかし午後4時半、Checkoutサービスがクラッシュしました。Checkoutサービスはそのフィールドをロジックには使用していませんでしたが、レガシーなデータパーサーが、フィールドが欠けている場合にエラーを吐く設定になっていたのです。たった1つの「非推奨」な文字列を削除しただけで、決済フロー全体を意図せず麻痺させてしまいました。これこそが「統合地獄(Integration Hell)」の定義です。個々のサービスは単体では完璧に動作しても、互いに通信した瞬間に失敗するのです。

なぜ従来のテスト手法は分散システムで失敗するのか

モノリスな環境では、コンパイラがセーフティネットになります。メソッドのシグネチャを変更すれば、コードはビルドすら通りません。しかし、ネットワークによって疎結合されているマイクロサービスには、そのような恩恵はありません。多くのチームは、一般的ではあるものの欠陥のある2つの戦略でこの問題を解決しようとします。

E2Eテストの罠

エンドツーエンド(E2E)テストでは、サービスA、サービスB、データベース、キャッシュなど、エコシステム全体を立ち上げる必要があります。網羅的ではありますが、実行速度が極めて遅いのが難点です。E2Eスイートの実行に45分もかかり、しかも些細なネットワークの瞬断やデータベースの古いレコードのせいで失敗するケースを何度も見てきました。E2Eテストが失敗したとき、その根本原因を特定するのは、干し草の山から針を探すようなものです。

共有ライブラリのボトルネック

プロデューサー(提供側)とコンシューマ(利用側)の間でDTO(Data Transfer Object)ライブラリを共有するチームもあります。これは「分散モノリス」を作り出してしまいます。プロデューサーがライブラリを更新すると、すべてのコンシューマは即座に依存関係を更新せざるを得なくなります。これは、サービスを個別にデプロイできるというマイクロサービスの最大の利点を損なうことになります。

コンシューマ駆動契約テスト(CDCT)の必要性

契約テスト(Contract Testing)はこの構図を逆転させます。システム全体をテストする代わりに、2つのサービス間で正式な合意(契約)を作成します。「コンシューマ」(APIを呼び出す側)が、自分が必要なものを正確に定義します。もし「プロバイダ」(API側)がこの合意に違反する変更を加えた場合、コードが開発者のマシンを離れる前にビルドが失敗します。

Pactはこのアプローチにおいて標準的なツールとなっています。コンシューマがJSON形式の「契約(Contract)」を生成し、プロバイダはその実装が契約に沿っているかを検証します。これをマスターすることは、ジュニアから、複雑な分散アーキテクチャを管理できるシニアエンジニアへとステップアップするための大きな一歩となります。

Pactの実装:実践的なチュートリアル

この例ではNode.jsを使用しますが、PactはJava、Python、Goなどでも同様に動作します。Order-Service(コンシューマ)とProduct-Service(プロバイダ)があると仮定しましょう。

ステップ1:コンシューマ側(要件の定義)

コンシューマは、期待するインタラクションを記述したテストを書きます。このフェーズでは、Pactライブラリを使用してプロバイダをモックします

const { Pact } = require('@pact-foundation/pact');
const path = require('path');

const provider = new Pact({
  consumer: 'OrderService',
  provider: 'ProductService',
  port: 1234,
  log: path.resolve(process.cwd(), 'logs', 'pact.log'),
  dir: path.resolve(process.cwd(), 'pacts'),
  spec: 2
});

describe('ProductServiceとのPactテスト', () => {
  beforeAll(() => provider.setup());
  afterEach(() => provider.verify());
  afterAll(() => provider.finalize());

  it('IDを指定したときに製品情報を返すこと', async () => {
    await provider.addInteraction({
      state: 'ID 10の製品が存在する状態',
      uponReceiving: '製品10への取得リクエスト',
      withRequest: {
        method: 'GET',
        path: '/products/10'
      },
      willRespondWith: {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: '10',
          name: 'メカニカルキーボード',
          price: 150
        }
      }
    });

    const result = await fetchProduct('10'); 
    expect(result.name).toEqual('メカニカルキーボード');
  });
});

このテストを実行すると、Pactはモックサーバーとして動作します。テストがパスすると、Pactは /pacts フォルダにJSONファイルを生成します。これが正式な「契約」となります。

ステップ2:プロバイダ側(契約の検証)

Product-Service チームはそのJSONファイルを受け取り、自社のサービスに対して実行します。複雑なモックを書く必要はありません。Pact Verifierが、契約にあるリクエストを実際の稼働しているプロバイダに対して再現(リプレイ)するだけです。

const { Verifier } = require('@pact-foundation/pact');

describe('Pact検証', () => {
  it('OrderServiceの期待値を検証すること', () => {
    const opts = {
      provider: 'ProductService',
      providerBaseUrl: 'http://localhost:8080',
      pactUrls: [path.resolve(process.cwd(), './pacts/orderservice-productservice.json')],
      stateHandlers: {
        'ID 10の製品が存在する状態': () => {
          // テスト用データベースに製品10のデータを投入
          return Promise.resolve('データ投入完了');
        }
      }
    };

    return new Verifier().verifyProvider(opts);
  });
});

もし開発者が nameproductName にリネームした場合、このテストは即座に失敗します。コードをコミットする前に、破壊的変更に気づくことができるのです。

ミッシングリンク:Pact Broker

チーム間でJSONファイルをメールで送り合うのは、混乱の元です。プロフェッショナルなCI/CDパイプラインでは、Pact Brokerを使用します。これは、コンシューマが契約をアップロードし、プロバイダが検証のためにダウンロードする中央ハブです。

特に can-i-deploy ツールの使用を強くお勧めします。デプロイスクリプトに pact-broker can-i-deploy --pacticipant OrderService --version $GIT_COMMIT --to prod という1行を追加するだけで、特定のバージョンが現在の本番環境のパートナーに対して検証済みかどうかをBrokerの行列(マトリックス)で確認できます。検証に失敗していれば、デプロイは自動的に停止します。

契約テストを成功させるためのルール

いくつかの大規模プロジェクトでPactを導入した経験から、プロセスをスムーズにする3つのルールを見つけました:

  1. 消費するものだけをテストする: プロバイダが50個のフィールドを返しても、自分たちが3個しか使わないのであれば、Pactにはその3個だけを定義してください。これにより、プロバイダが残りの47個のフィールドを自由に変更しても、不要なアラートが発生しなくなります。
  2. ハードコードされた値よりもマッチャー(Matchers)を優先する: "price": 150 と直接チェックするのではなく、Term.like(150) のような型マッチャーを使用してください。これにより、データの内容が少し変わってもテストが壊れにくくなります。
  3. 状態管理(State Management)に投資する: stateHandlers は信頼性が高くなければなりません。テストが「期限切れのクレジットカードを持つユーザー」を期待しているなら、プロバイダがテスト環境でその状況を一貫してセットアップできるように徹底してください。

契約テストは初期のセットアップに投資が必要ですが、デプロイに対する不安を解消する最も効果的な方法です。統合チェックを開発サイクルの早い段階(シフトレフト)に持ってくることで、システムを壊す恐怖を感じることなく、マイクロサービスを迅速に進化させることができるようになります。

Share: