PactとTypeScriptで実践するコンシューマ駆動契約テスト(CDCT)実践ガイド

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

背景と重要性:API統合における摩擦の解消

マイクロサービスはエンジニアリングチームに自律性と開発スピードをもたらしますが、サービス境界をまたぐ統合はしばしば予期せぬトラブルの温床になります。バックエンドエンジニアがスプリントリリース時にuserIdをidに変更したり、レスポンスステータスを200 OKから204 No Contentに変更したり、非推奨フィールドを削除したりすることで、フロントエンドアプリが突然動作しなくなることがあります。

通常、チームは以下の2つの不完全なテスト戦略に頼りがちです。

  • モックに依存したユニットテスト: 実行が高速でコストもほぼかかりません。しかし、本番サービスの契約と突き合わせて検証されないため、フロントエンドの静的モックは数日で実際のAPIと乖離してしまいます。
  • ステージング環境でのエンドツーエンド(E2E)テスト: 実行が遅く、壊れやすく、保守コストが高いのが難点です。ステージングでE2Eテストが失敗すると、エンジニアは複数のダウンストリームサービスにまたがる競合状態や不正なテストフィクスチャの原因特定に3〜4時間も費やすことになります。

コンシューマ駆動契約テスト(CDCT: Consumer-Driven Contract Testing)はこのジレンマを解決します。APIスキーマを推測したりステージング環境を待ったりする代わりに、コンシューマ(ReactやNode.jsのクライアントなど)側が必要とするリクエストパターンと期待するレスポンスの構造をコードで定義します。そして、その期待値を契約ファイル(Pact)として保存します。バックエンドプロバイダーはその契約を取得し、実際のAPIエンドポイントがそのルールを満たしているかを独立して検証します。

この仕組みを12のマイクロサービスに導入したところ、ステージング環境での統合障害が80%減少しました。リスクの高い一斉同調リリースの調整作業がなくなり、毎日5回以上の独立した本番デプロイが可能になりました。

導入手順:TypeScriptプロジェクトへのPactのセットアップ

Pact Foundationは@pact-foundation/pactを通じてネイティブなTypeScriptバインディングを提供しています。TypeScriptとJestを使ってコンシューマテストスイートを構築してみましょう。

ワークスペースを初期化し、必要な依存パッケージをインストールします。

npm init -y
npm install --save-dev typescript jest ts-jest @types/jest @pact-foundation/pact axios
npx ts-jest config:init

package.jsonに専用のテストコマンドを追加します。

{
  "scripts": {
    "test:pact": "jest --testMatch='**/*.pact.test.ts' --runInBand"
  }
}

なぜ--runInBandを指定するのでしょうか?Pactはテストスイートごとに軽量なモックHTTPサーバーを起動します。テストを順番(直列)に実行することで、localhostでのポート競合やレースコンディションを防ぐことができます。

設定:コンシューマの期待値定義とPactファイルの生成

契約テストはクライアント側から始まります。テストでは、Pactが起動したモックサーバーに対して実際のAPIクライアントを実行します。すべてのアサーションに合格すると、PactはそのインタラクションをJSON形式の契約ファイルとして出力します。

1. コンシューマAPIクライアント

以下は、バックエンドサービスからユーザー詳細を取得する型定義付きHTTPクライアントの例です。

import axios from 'axios';

export interface User {
  id: string;
  name: string;
  email: string;
  role: string;
}

export class UserApiClient {
  constructor(private baseUrl: string) {}

  async getUser(id: string): Promise<User> {
    const response = await axios.get<User>(`${this.baseUrl}/users/${id}`);
    return response.data;
  }
}

2. 柔軟なコンシューマアサーションの作成

ハードコードされた厳密な値の検証は避けてください。'John Doe'のような完全一致の文字列でアサーションを行うと、テスト用データベースのフィクスチャが変更されるたびにバックエンドのテストが失敗してしまいます。代わりにPact V3のMatcherを使用しましょう。データ型、フォーマット、正規表現パターンなどを検証することで、実害のないデータ変動による誤検知を防ぐことができます。

import path from 'path';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { UserApiClient } from './userApiClient';

const { like, email, regex } = MatchersV3;

const provider = new PactV3({
  consumer: 'FrontendWebApp',
  provider: 'UserService',
  dir: path.resolve(process.cwd(), 'pacts'),
});

describe('UserApiClient 契約テスト', () => {
  it('存在するユーザーのプロファイルを正常に取得できること', async () => {
    provider
      .given('ID 101のユーザーが存在する')
      .uponReceiving('ユーザー101を取得するGETリクエスト')
      .withRequest({
        method: 'GET',
        path: '/users/101',
      })
      .willRespondWith({
        status: 200,
        headers: {
          'Content-Type': 'application/json; charset=utf-8',
        },
        body: {
          id: regex('^\\d+$', '101'),
          name: like('John Doe'),
          email: email('[email protected]'),
          role: like('ADMIN'),
        },
      });

    await provider.executeTest(async (mockServer) => {
      const client = new UserApiClient(mockServer.url);
      const user = await client.getUser('101');

      expect(user.id).toBe('101');
      expect(user.name).toBe('John Doe');
      expect(user.role).toBe('ADMIN');
    });
  });
});

npm run test:pactを実行すると、テストスイートがコンパイルされ、pacts/FrontendWebApp-UserService.jsonがディスクに出力されます。

検証とモニタリング:プロバイダー検証とCI/CDの自動化

契約は、プロバイダー側が継続的に検証して初めて価値を持ちます。バックエンドのリポジトリでは、記録されたコンシューマのインタラクションを実際のルートハンドラーに対して直接再現する検証スイートを設定します。

1. プロバイダー検証スクリプト

このテストはバックエンドサーバーを起動し、Pactに対してすべての契約ペイロードをサーバーに向けて再現するように指示します。

import path from 'path';
import { Verifier } from '@pact-foundation/pact';
import { startServer, stopServer } from '../src/server';

describe('Pactプロバイダー検証', () => {
  const PORT = 8080;
  let server: any;

  beforeAll(async () => {
    server = await startServer(PORT);
  });

  afterAll(async () => {
    await stopServer(server);
  });

  it('コンシューマ契約に対してリクエストを検証する', async () => {
    const verifier = new Verifier({
      provider: 'UserService',
      providerBaseUrl: `http://localhost:${PORT}`,
      pactUrls: [
        path.resolve(process.cwd(), '../frontend/pacts/FrontendWebApp-UserService.json'),
      ],
      stateHandlers: {
        'ID 101のユーザーが存在する': async () => {
          // 一時的なテストデータベースレコードをシード
          return Promise.resolve('データベースにユーザー101をシードしました');
        },
      },
    });

    const output = await verifier.verifyProvider();
    console.log('Pact検証完了:', output);
  });
});

2. Pact BrokerとCI/CDによる契約チェックの自動化

プルリクエストや共有ストレージを介してJSONファイルを手動でやり取りする方法はすぐに破綻します。専用のPact Broker(またはホスト型のPactFlowインスタンス)を利用することで、契約の一元管理レジストリおよびステータストラッカーとして機能させることができます。

堅牢な自動化パイプラインは、以下の3つのフェーズで動作します。

  1. フロントエンドCI: コンシューマテストを実行して契約ファイルを生成し、GitコミットSHAやブランチ名をタグ付けしてBrokerに発行します。
  2. バックエンドCI: Brokerから発行済みのコンシューマ契約を取得し、対象のバックエンドビルドに対して検証を行い、検証結果をレジストリに登録します。
  3. デプロイガード(can-i-deploy): 本番デプロイの前にBrokerへ問い合わせを行い、コンシューマとプロバイダー間の完全なバージョン互換性を保証します。
# FrontendWebAppが本番環境へ安全にデプロイ可能かを検証
npx pact-broker can-i-deploy \
  --pacticipant FrontendWebApp \
  --version $GIT_COMMIT_SHA \
  --to-environment production \
  --broker-base-url https://your-broker.pactflow.io \
  --broker-token $PACT_BROKER_TOKEN

3. 本番運用チームのための実践的Tips

  • 実際に利用するフィールドのみを検証する: フロントエンドでnameとemailしか描画しないのであれば、created_atや内部メタデータは契約に含めないようにします。これにより、バックエンドチームは契約違反を起こすことなく内部データをリファクタリングできます。
  • プロバイダー状態を動的に分離する: 壊れやすい静的な初期データに頼るのではなく、stateHandlersを使用して各インタラクションの実行直前に一時的なフィクスチャをテストデータベースに注入します。
  • Webhookで検証を自動トリガーする: フロントエンドのPRによって変更された契約が発行された際に、バックエンドリポジトリのプロバイダー検証ビルドを自動的にトリガーするようPact BrokerのWebhookを設定します。
Share: