T-CREATOR

Playwright で API リクエストを自在にモックする

Playwright で API リクエストを自在にモックする

現代の Web アプリケーション開発において、API との連携は避けて通れない重要な要素となりました。しかし、E2E テストを実行する際に外部 API に依存することで、テストの安定性や実行速度に悩まされた経験はありませんか。

Playwright の API モック機能を使えば、これらの課題を一気に解決できます。この記事では、初心者の方でも段階的に理解できるよう、基本的な概念から実践的な応用まで丁寧に解説いたします。実際のコード例とともに、あなたのテスト環境を劇的に改善する方法をお伝えします。

背景

Playwright とは何か

Playwright は Microsoft が開発したモダンな Web アプリケーション向けのテストフレームワークです。従来の Selenium とは異なり、Chromium、Firefox、Safari といった複数のブラウザを同一の API で制御できる革新的なツールとなっています。

特に注目すべきは、ネットワークレベルでのインターセプト機能です。これにより、ブラウザとサーバー間の通信を完全に制御し、任意のレスポンスを返すことが可能になりました。

E2E テストでの API モックの重要性

E2E テストは実際のユーザー操作を再現し、アプリケーション全体の動作を検証する貴重な手法です。しかし、外部 API に依存するテストには以下のような課題があります。

現実的な問題として、API サーバーの状況によってテスト結果が左右されてしまいます。また、特定のエラー状況を再現するために、わざわざ API サーバー側でエラーを発生させるのは現実的ではありません。

従来の方法との違い

従来のモック手法では、JavaScript レベルでの置き換えが主流でした。fetch 関数や XMLHttpRequest をモックライブラリで置き換える方法です。

しかし、Playwright のアプローチはネットワークレベルでの制御となります。これにより、実際のブラウザ環境により近い条件でテストを実行できるのです。

課題

外部 API への依存による不安定なテスト

E2E テストを実行していて、以下のようなエラーに遭遇したことはありませんでしょうか。

javascriptTimeoutError: Timeout 30000ms exceeded.
=========================== logs ===========================
waiting for selector "[data-testid=user-profile]"

このエラーは、API からのレスポンスが遅延したり、一時的に利用できない状況で発生します。本来はアプリケーションの問題ではないにも関わらず、テストが失敗してしまう厄介な問題です。

実際の開発現場では、テストの成功率が外部サービスの安定性に左右されるという課題に直面します。特に、複数の外部 API を利用するアプリケーションでは、この問題が顕著に現れます。

テストデータの準備の困難さ

E2E テストでは、特定の状況を再現するために適切なテストデータが必要です。例えば、以下のようなシナリオを考えてみましょう。

  • 新規ユーザーの登録フロー
  • エラーが発生した場合の処理
  • 大量のデータが存在する場合の表示

これらのテストデータを毎回 API サーバー側で準備するのは、時間とコストの面で現実的ではありません。また、テスト実行後のデータクリーンアップも煩雑な作業となってしまいます。

レスポンス時間の制御問題

実際のアプリケーションでは、ネットワークの遅延やサーバーの負荷によってレスポンス時間が変動します。しかし、これらの状況をテストで再現するのは困難です。

javascript// よくある失敗パターン
test('ローディング表示のテスト', async ({ page }) => {
  await page.goto('/dashboard');
  // API レスポンスが早すぎてローディングが見えない
  await expect(page.locator('[data-testid=loading]')).toBeVisible();
});

上記のようなテストは、開発環境では API のレスポンスが早すぎて、ローディング表示のテストが正しく動作しない場合があります。

解決策

Playwright の Route API を使った基本的なモック

Playwright では page.route() メソッドを使用して、HTTP リクエストをインターセプトし、任意のレスポンスを返すことができます。これにより、外部 API に依存しない安定したテストが実現できます。

基本的な構文は以下のとおりです。このシンプルな API が、複雑な問題を一気に解決してくれるのです。

javascript// 基本的なモックの設定
await page.route('**/api/users', route => {
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([
      { id: 1, name: '田中太郎', email: 'tanaka@example.com' }
    ])
  });
});

この方法なら、外部 API の状況に左右されることなく、常に期待通りの結果でテストを実行できます。

Mock API の設定方法

実際のプロジェクトでは、モックの設定を効率的に管理する必要があります。以下のような表でパターンを整理すると、理解しやすくなります。

#パラメータ説明必須
1URL パターンインターセプトする URL
2statusHTTP ステータスコード
3contentTypeレスポンスの Content-Type
4bodyレスポンスボディ
5headers追加のヘッダー情報

以下は実践的な設定例です。この例では、複数の条件を組み合わせてより柔軟なモックを作成しています。

javascript// より実践的なモック設定
await page.route('**/api/**', route => {
  const url = route.request().url();
  const method = route.request().method();
  
  // URL と HTTP メソッドに基づいて処理を分岐
  if (url.includes('/users') && method === 'GET') {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      headers: {
        'x-total-count': '150',
        'x-page-size': '20'
      },
      body: JSON.stringify({
        users: [
          { id: 1, name: '田中太郎' },
          { id: 2, name: '佐藤花子' }
        ],
        pagination: {
          total: 150,
          page: 1,
          size: 20
        }
      })
    });
  }
});

ハンドラー関数の実装

モックハンドラーをより保守性の高い形で実装するには、関数として切り出すことが重要です。

javascript// モックハンドラーを関数として分離
const createUserMockHandler = (users) => {
  return async (route) => {
    const request = route.request();
    const url = new URL(request.url());
    
    // クエリパラメータの処理
    const page = parseInt(url.searchParams.get('page') || '1');
    const limit = parseInt(url.searchParams.get('limit') || '10');
    
    const startIndex = (page - 1) * limit;
    const endIndex = startIndex + limit;
    const paginatedUsers = users.slice(startIndex, endIndex);
    
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        data: paginatedUsers,
        total: users.length,
        page,
        limit
      })
    });
  };
};

この関数型のアプローチにより、再利用性と可読性が大幅に向上します。複数のテストファイルで同じモックロジックを使用できるようになります。

具体例

基本的な GET リクエストのモック

最も基本的な GET リクエストのモックから始めましょう。ユーザー一覧を取得する API をモックする例です。

実際のテストでは、以下のようにモックを設定してからページにアクセスします。この順序が重要で、モックの設定はページアクセス前に行う必要があります。

javascriptimport { test, expect } from '@playwright/test';

test('ユーザー一覧の表示テスト', async ({ page }) => {
  // まず API モックを設定
  await page.route('**/api/users', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        {
          id: 1,
          name: '田中太郎',
          email: 'tanaka@example.com',
          role: 'manager'
        },
        {
          id: 2,
          name: '佐藤花子',
          email: 'sato@example.com',
          role: 'developer'
        }
      ])
    });
  });

  // ページにアクセス
  await page.goto('/users');
  
  // モックデータが正しく表示されることを検証
  await expect(page.locator('[data-testid=user-1]')).toContainText('田中太郎');
  await expect(page.locator('[data-testid=user-2]')).toContainText('佐藤花子');
});

このテストは外部 API に依存せず、常に同じ結果を返すため、安定性が格段に向上します。

POST リクエストのモック

次に、データを送信する POST リクエストのモックを見てみましょう。ユーザー作成 API のモック例です。

javascripttest('新規ユーザー作成のテスト', async ({ page }) => {
  // POST リクエストのモック設定
  await page.route('**/api/users', async route => {
    const request = route.request();
    
    // リクエストメソッドの確認
    if (request.method() === 'POST') {
      // リクエストボディの取得と検証
      const postData = request.postDataJSON();
      
      await route.fulfill({
        status: 201,
        contentType: 'application/json',
        body: JSON.stringify({
          id: 3,
          name: postData.name,
          email: postData.email,
          role: postData.role,
          createdAt: new Date().toISOString()
        })
      });
    }
  });

  await page.goto('/users/create');
  
  // フォームに入力
  await page.fill('[data-testid=name-input]', '新規ユーザー');
  await page.fill('[data-testid=email-input]', 'newuser@example.com');
  await page.selectOption('[data-testid=role-select]', 'developer');
  
  // 送信ボタンをクリック
  await page.click('[data-testid=submit-button]');
  
  // 成功メッセージの確認
  await expect(page.locator('[data-testid=success-message]')).toBeVisible();
});

この例では、送信されたデータを受け取って、それを含む形でレスポンスを返しています。実際の API の動作により近いモックを作成できます。

動的レスポンスの作成

実際のアプリケーションでは、リクエストの内容に応じて動的にレスポンスを変える必要があります。検索機能のモック例を見てみましょう。

javascripttest('ユーザー検索機能のテスト', async ({ page }) => {
  // 全ユーザーデータを用意
  const allUsers = [
    { id: 1, name: '田中太郎', department: '営業部' },
    { id: 2, name: '佐藤花子', department: '開発部' },
    { id: 3, name: '田中次郎', department: '人事部' },
    { id: 4, name: '鈴木一郎', department: '開発部' }
  ];

  await page.route('**/api/users/search**', async route => {
    const url = new URL(route.request().url());
    const query = url.searchParams.get('q') || '';
    const department = url.searchParams.get('department') || '';
    
    // 検索条件に基づいてフィルタリング
    let filteredUsers = allUsers;
    
    if (query) {
      filteredUsers = filteredUsers.filter(user => 
        user.name.includes(query)
      );
    }
    
    if (department) {
      filteredUsers = filteredUsers.filter(user => 
        user.department === department
      );
    }

    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        results: filteredUsers,
        total: filteredUsers.length,
        query: { q: query, department: department }
      })
    });
  });

  await page.goto('/users/search');
  
  // 「田中」で検索
  await page.fill('[data-testid=search-input]', '田中');
  await page.click('[data-testid=search-button]');
  
  // 2件の結果が表示されることを確認
  await expect(page.locator('[data-testid=search-results] .user-item')).toHaveCount(2);
});

このように、リクエストパラメータに基づいて動的にレスポンスを生成することで、より現実的なテストシナリオを作成できます。

エラーレスポンスのテスト

実際のアプリケーションでは、API エラーが発生した場合の処理も重要なテスト項目です。エラーハンドリングをテストする方法を見てみましょう。

javascripttest('API エラー時の表示テスト', async ({ page }) => {
  // 500 エラーを返すモック
  await page.route('**/api/users', async route => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({
        error: 'Internal Server Error',
        message: 'データベース接続エラーが発生しました',
        code: 'DB_CONNECTION_ERROR'
      })
    });
  });

  await page.goto('/users');
  
  // エラーメッセージが表示されることを確認
  await expect(page.locator('[data-testid=error-message]')).toBeVisible();
  await expect(page.locator('[data-testid=error-message]'))
    .toContainText('データベース接続エラーが発生しました');
});

認証エラーのテストも重要です。以下は 401 Unauthorized エラーをテストする例です。

javascripttest('認証エラー時のリダイレクトテスト', async ({ page }) => {
  // 401 エラーを返すモック
  await page.route('**/api/protected-data', async route => {
    await route.fulfill({
      status: 401,
      contentType: 'application/json',
      body: JSON.stringify({
        error: 'Unauthorized',
        message: '認証が必要です',
        code: 'AUTH_REQUIRED'
      })
    });
  });

  await page.goto('/dashboard');
  
  // ログインページにリダイレクトされることを確認
  await expect(page).toHaveURL('/login');
  await expect(page.locator('[data-testid=login-required-message]'))
    .toContainText('ログインが必要です');
});

バリデーションエラーのテストでは、422 Unprocessable Entity を使用します。

javascripttest('バリデーションエラーの表示テスト', async ({ page }) => {
  await page.route('**/api/users', async route => {
    if (route.request().method() === 'POST') {
      await route.fulfill({
        status: 422,
        contentType: 'application/json',
        body: JSON.stringify({
          error: 'Validation Error',
          errors: {
            email: ['メールアドレスの形式が正しくありません'],
            name: ['名前は必須項目です']
          }
        })
      });
    }
  });

  await page.goto('/users/create');
  
  // 不正なデータでフォーム送信
  await page.fill('[data-testid=name-input]', '');
  await page.fill('[data-testid=email-input]', 'invalid-email');
  await page.click('[data-testid=submit-button]');
  
  // バリデーションエラーが表示されることを確認
  await expect(page.locator('[data-testid=email-error]'))
    .toContainText('メールアドレスの形式が正しくありません');
  await expect(page.locator('[data-testid=name-error]'))
    .toContainText('名前は必須項目です');
});

複数 API の同時モック

実際のアプリケーションでは、複数の API を同時に呼び出すことがよくあります。ダッシュボードページのような複雑なページをテストする例を見てみましょう。

javascripttest('ダッシュボードの複数 API テスト', async ({ page }) => {
  // ユーザー情報 API のモック
  await page.route('**/api/user/profile', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        id: 1,
        name: '田中太郎',
        email: 'tanaka@example.com',
        avatar: '/avatars/tanaka.jpg'
      })
    });
  });

  // 統計情報 API のモック
  await page.route('**/api/stats', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        totalUsers: 1250,
        activeUsers: 892,
        totalSales: 2500000,
        monthlyGrowth: 12.5
      })
    });
  });

  // 最近のアクティビティ API のモック
  await page.route('**/api/activities/recent', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        {
          id: 1,
          type: 'user_registered',
          message: '新規ユーザーが登録されました',
          timestamp: new Date().toISOString()
        },
        {
          id: 2,
          type: 'order_completed',
          message: '注文が完了しました',
          timestamp: new Date(Date.now() - 3600000).toISOString()
        }
      ])
    });
  });

  await page.goto('/dashboard');
  
  // それぞれの API データが正しく表示されることを確認
  await expect(page.locator('[data-testid=user-name]')).toContainText('田中太郎');
  await expect(page.locator('[data-testid=total-users]')).toContainText('1,250');
  await expect(page.locator('[data-testid=monthly-growth]')).toContainText('12.5%');
  await expect(page.locator('[data-testid=recent-activities] .activity-item')).toHaveCount(2);
});

この例では、3つの異なる API を同時にモックしています。各 API が独立してモックされているため、一つの API に問題があっても他のテストに影響しません。

さらに高度な例として、API の呼び出し順序や依存関係をテストすることも可能です。

javascripttest('API 呼び出し順序のテスト', async ({ page }) => {
  const apiCalls = [];

  // すべての API 呼び出しを記録
  await page.route('**/api/**', async route => {
    const url = route.request().url();
    apiCalls.push(url);
    
    if (url.includes('/user/profile')) {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ id: 1, name: 'Test User' })
      });
    } else if (url.includes('/user/preferences')) {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ theme: 'dark', language: 'ja' })
      });
    }
  });

  await page.goto('/settings');
  
  // API が正しい順序で呼び出されたことを確認
  expect(apiCalls).toEqual([
    expect.stringContaining('/user/profile'),
    expect.stringContaining('/user/preferences')
  ]);
});

このように、API の呼び出しパターンや順序も検証できるため、アプリケーションの動作をより詳細にテストできます。

まとめ

この記事では、Playwright の API モック機能について基本から応用まで詳しく解説いたしました。従来の外部 API に依存するテストから脱却し、安定性と効率性を両立したテスト環境を構築する方法をお伝えしました。

学んだポイントの振り返り

#項目得られる効果
1基本的なモック設定外部 API への依存を排除し、テストの安定性向上
2動的レスポンス生成より現実的なテストシナリオの実現
3エラーハンドリングテストアプリケーションの堅牢性を確保
4複数 API の同時制御複雑なページやワークフローのテスト
5パフォーマンステストレスポンス時間の制御とローディング状態の検証

実装時のベストプラクティス

Playwright API モックを効果的に活用するために、以下の点を心がけてください。

モックの設定はページアクセス前に行うことが重要です。この順序を守らないと、リクエストが実際の API に送信されてしまいます。

テストデータは再利用可能な形で管理しましょう。共通のモックハンドラーを作成することで、メンテナンスの負荷を大幅に削減できます。

エラーケースのテストを忘れずに実装してください。正常系のテストだけでは、実際の運用で発生する問題を見落とす可能性があります。

得られる価値

Playwright の API モック機能を導入することで、以下のような価値を得ることができます。

開発効率の向上は最も実感しやすい効果でしょう。テストの実行時間が短縮され、外部サービスの障害による影響を受けなくなります。

品質の向上も見逃せません。様々なエラーケースやエッジケースを確実にテストできるため、より堅牢なアプリケーションを構築できます。

チーム全体の生産性向上につながります。安定したテスト環境により、CI/CD パイプラインが安定し、デプロイメントの信頼性が向上します。

次のステップ

今回学んだ内容を踏まえて、以下のような発展的な取り組みにチャレンジしてみてください。

GraphQL API のモックや、WebSocket 通信のモックなど、より高度なケースにも対応できるようになります。

パフォーマンステストとの組み合わせも有効です。レスポンス時間を意図的に調整することで、様々なネットワーク環境での動作を検証できます。

API 仕様書との連携を自動化することで、モックデータの整合性を保つ仕組みも構築できます。

Playwright の API モック機能は、現代の Web アプリケーション開発において必要不可欠なツールです。この記事で学んだ知識を活かして、より効率的で信頼性の高いテスト環境を構築していただければと思います。あなたの開発体験が大きく改善されることを心から願っています。

関連リンク

公式ドキュメント

実践的なリソース

コミュニティとサポート