10日で覚えるPlaywrightDay 6: ネットワーク制御とモック
books.chapter 610日で覚えるPlaywright

Day 6: ネットワーク制御とモック

今日学ぶこと

  • page.on('request') / page.on('response') によるネットワーク監視
  • waitForRequest() / waitForResponse() によるリクエスト待機
  • page.route() によるルートインターセプト
  • route.fulfill() によるAPIレスポンスのモック
  • route.continue() によるリクエストの変更
  • route.abort() によるリクエストの中断
  • HAR記録と再生(page.routeFromHAR())
  • ネットワーク条件のエミュレーション(オフライン、低速3G)
  • リクエストコンテキストによるAPIテスト
  • 実践:モックバックエンドを使ったテスト

ネットワーク制御の全体像

Playwrightは、ブラウザのネットワーク通信を完全に制御するための豊富なAPIを提供しています。

flowchart TB
    subgraph Monitor["監視"]
        OnReq["page.on('request')"]
        OnRes["page.on('response')"]
    end

    subgraph Wait["待機"]
        WaitReq["waitForRequest()"]
        WaitRes["waitForResponse()"]
    end

    subgraph Intercept["インターセプト"]
        Route["page.route()"]
        Fulfill["route.fulfill()"]
        Continue["route.continue()"]
        Abort["route.abort()"]
    end

    subgraph Advanced["高度な機能"]
        HAR["routeFromHAR()"]
        Offline["オフライン\nエミュレーション"]
        API["APIテスト\nrequest context"]
    end

    Monitor --> Wait --> Intercept --> Advanced

    style Monitor fill:#3b82f6,color:#fff
    style Wait fill:#8b5cf6,color:#fff
    style Intercept fill:#f59e0b,color:#fff
    style Advanced fill:#22c55e,color:#fff

ネットワークリクエストの監視

page.on('request') と page.on('response')

ページで発生するすべてのネットワークリクエストとレスポンスをリアルタイムで観察できます。

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

test('monitor network requests', async ({ page }) => {
  const requests: string[] = [];

  // All requests
  page.on('request', (request) => {
    console.log(`>> ${request.method()} ${request.url()}`);
    requests.push(request.url());
  });

  // All responses
  page.on('response', (response) => {
    console.log(`<< ${response.status()} ${response.url()}`);
  });

  await page.goto('https://example.com');
  expect(requests.length).toBeGreaterThan(0);
});

リクエスト失敗の検知

page.on('requestfailed', (request) => {
  console.log(`FAILED: ${request.url()} - ${request.failure()?.errorText}`);
});

リクエストの待機

waitForRequest() と waitForResponse()

特定のリクエストやレスポンスの発生を待機できます。

test('wait for specific API call', async ({ page }) => {
  // URL文字列で待機
  const responsePromise = page.waitForResponse('**/api/users');

  await page.goto('/users');

  const response = await responsePromise;
  expect(response.status()).toBe(200);

  const data = await response.json();
  expect(data).toHaveLength(3);
});

条件付き待機

test('wait with predicate', async ({ page }) => {
  // 条件関数で待機
  const responsePromise = page.waitForResponse(
    (response) =>
      response.url().includes('/api/users') &&
      response.status() === 200
  );

  await page.click('#load-users');

  const response = await responsePromise;
  const users = await response.json();
  expect(users.length).toBeGreaterThan(0);
});

POST リクエストの待機

test('wait for POST request', async ({ page }) => {
  const requestPromise = page.waitForRequest(
    (request) =>
      request.url().includes('/api/users') &&
      request.method() === 'POST'
  );

  await page.fill('#name', '山田太郎');
  await page.click('#submit');

  const request = await requestPromise;
  const postData = request.postDataJSON();
  expect(postData.name).toBe('山田太郎');
});

page.route() によるインターセプト

基本的な使い方

page.route() はリクエストをインターセプトし、レスポンスをモック、変更、または中断するための中心的なAPIです。

flowchart LR
    Browser["ブラウザ"] --> |"リクエスト"| Route["page.route()"]
    Route --> |"fulfill()"| Mock["モックレスポンス"]
    Route --> |"continue()"| Server["サーバー\n(変更付き)"]
    Route --> |"abort()"| Block["ブロック"]

    style Browser fill:#3b82f6,color:#fff
    style Route fill:#f59e0b,color:#fff
    style Mock fill:#8b5cf6,color:#fff
    style Server fill:#22c55e,color:#fff
    style Block fill:#ef4444,color:#fff

URLパターンのマッチング

// グロブパターン
await page.route('**/api/users', handler);
await page.route('**/api/users/*', handler);

// 正規表現
await page.route(/\/api\/users\/\d+/, handler);

// 条件関数
await page.route(
  (url) => url.pathname.startsWith('/api/'),
  handler
);

route.fulfill() - レスポンスのモック

JSONレスポンスを返す

test('mock API response', async ({ page }) => {
  await page.route('**/api/users', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: '山田太郎', email: 'yamada@example.com' },
        { id: 2, name: '鈴木花子', email: 'suzuki@example.com' },
      ]),
    });
  });

  await page.goto('/users');
  await expect(page.locator('.user-card')).toHaveCount(2);
  await expect(page.locator('.user-card').first()).toContainText('山田太郎');
});

エラーレスポンスのシミュレーション

test('handle 500 error', async ({ page }) => {
  await page.route('**/api/users', async (route) => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal Server Error' }),
    });
  });

  await page.goto('/users');
  await expect(page.locator('.error-message')).toBeVisible();
});

ファイルからレスポンスを返す

test('mock with file', async ({ page }) => {
  await page.route('**/api/users', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      path: 'tests/fixtures/users.json',
    });
  });

  await page.goto('/users');
});

route.continue() - リクエストの変更

リクエストをサーバーに送信しつつ、ヘッダーやURLを変更できます。

test('modify request headers', async ({ page }) => {
  await page.route('**/api/**', async (route) => {
    await route.continue({
      headers: {
        ...route.request().headers(),
        'X-Custom-Header': 'test-value',
        'Authorization': 'Bearer mock-token',
      },
    });
  });

  await page.goto('/dashboard');
});

URLの書き換え

test('rewrite API URL', async ({ page }) => {
  // staging APIをlocal APIにリダイレクト
  await page.route('**/api.staging.example.com/**', async (route) => {
    const url = route.request().url().replace(
      'api.staging.example.com',
      'localhost:3001'
    );
    await route.continue({ url });
  });
});

レスポンスの変更

test('modify response data', async ({ page }) => {
  await page.route('**/api/users', async (route) => {
    const response = await route.fetch();
    const json = await response.json();

    // Add a test user to the response
    json.push({ id: 999, name: 'テストユーザー', email: 'test@example.com' });

    await route.fulfill({
      response,
      body: JSON.stringify(json),
    });
  });

  await page.goto('/users');
});

route.abort() - リクエストの中断

不要なリクエスト(画像、アナリティクスなど)をブロックしてテストを高速化できます。

test('block images and analytics', async ({ page }) => {
  await page.route('**/*.{png,jpg,jpeg,gif,svg}', (route) => route.abort());
  await page.route('**/analytics/**', (route) => route.abort());
  await page.route('**/ads/**', (route) => route.abort());

  await page.goto('/');
  await expect(page.locator('h1')).toBeVisible();
});

リソースタイプでフィルタリング

await page.route('**/*', async (route) => {
  const resourceType = route.request().resourceType();
  if (['image', 'font', 'stylesheet'].includes(resourceType)) {
    await route.abort();
  } else {
    await route.continue();
  }
});
abort() の理由 説明
テスト高速化 不要なリソースの読み込みをスキップ
外部依存の排除 サードパーティスクリプトをブロック
エラーテスト ネットワークエラーのシミュレーション

HAR記録と再生

HARとは

HAR(HTTP Archive)はブラウザのネットワーク通信を記録するフォーマットです。実際のAPIレスポンスを記録し、テスト時に再生できます。

flowchart LR
    subgraph Record["記録フェーズ"]
        R1["実際のAPI通信"] --> R2["HARファイルに保存"]
    end

    subgraph Replay["再生フェーズ"]
        P1["HARファイル読み込み"] --> P2["記録済みレスポンスを返す"]
    end

    Record --> Replay

    style Record fill:#3b82f6,color:#fff
    style Replay fill:#22c55e,color:#fff

HAR記録

test('record HAR', async ({ page }) => {
  // Start recording
  await page.routeFromHAR('tests/fixtures/api.har', {
    update: true,  // Record mode
    url: '**/api/**',
  });

  await page.goto('/dashboard');
  await page.click('#load-data');
  await page.waitForResponse('**/api/data');
});

HAR再生

test('replay from HAR', async ({ page }) => {
  // Replay mode (default)
  await page.routeFromHAR('tests/fixtures/api.har', {
    url: '**/api/**',
  });

  await page.goto('/dashboard');
  await expect(page.locator('.data-table')).toBeVisible();
});

ネットワーク条件のエミュレーション

オフラインモード

test('offline mode', async ({ page, context }) => {
  await page.goto('/');

  // Go offline
  await context.setOffline(true);

  await page.click('#load-data');
  await expect(page.locator('.offline-message')).toBeVisible();

  // Go back online
  await context.setOffline(false);

  await page.click('#retry');
  await expect(page.locator('.data-loaded')).toBeVisible();
});

低速ネットワークのシミュレーション

CDP(Chrome DevTools Protocol)を使って低速回線をエミュレートできます(Chromiumのみ)。

test('slow 3G simulation', async ({ page }) => {
  const cdpSession = await page.context().newCDPSession(page);

  await cdpSession.send('Network.emulateNetworkConditions', {
    offline: false,
    downloadThroughput: (500 * 1024) / 8,  // 500kb/s
    uploadThroughput: (500 * 1024) / 8,
    latency: 400,  // 400ms RTT
  });

  await page.goto('/');
  // Slow loading behavior can be verified here
});

APIテスト(リクエストコンテキスト)

Playwrightはブラウザを使わずにAPIを直接テストする機能も備えています。

基本的なAPIテスト

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

test.describe('API Tests', () => {
  const BASE_URL = 'https://jsonplaceholder.typicode.com';

  test('GET /users', async ({ request }) => {
    const response = await request.get(`${BASE_URL}/users`);

    expect(response.ok()).toBeTruthy();
    expect(response.status()).toBe(200);

    const users = await response.json();
    expect(users).toHaveLength(10);
    expect(users[0]).toHaveProperty('name');
  });

  test('POST /posts', async ({ request }) => {
    const response = await request.post(`${BASE_URL}/posts`, {
      data: {
        title: 'Test Post',
        body: 'This is a test.',
        userId: 1,
      },
    });

    expect(response.status()).toBe(201);
    const post = await response.json();
    expect(post.title).toBe('Test Post');
  });

  test('PUT /posts/1', async ({ request }) => {
    const response = await request.put(`${BASE_URL}/posts/1`, {
      data: {
        title: 'Updated Title',
        body: 'Updated body.',
        userId: 1,
      },
    });

    expect(response.ok()).toBeTruthy();
    const post = await response.json();
    expect(post.title).toBe('Updated Title');
  });

  test('DELETE /posts/1', async ({ request }) => {
    const response = await request.delete(`${BASE_URL}/posts/1`);
    expect(response.ok()).toBeTruthy();
  });
});

認証付きAPIテスト

test('authenticated API request', async ({ request }) => {
  // Login to get token
  const loginResponse = await request.post('/api/login', {
    data: { email: 'user@example.com', password: 'password123' },
  });
  const { token } = await loginResponse.json();

  // Use token for subsequent requests
  const response = await request.get('/api/profile', {
    headers: { Authorization: `Bearer ${token}` },
  });

  expect(response.ok()).toBeTruthy();
  const profile = await response.json();
  expect(profile.email).toBe('user@example.com');
});

実践:モックバックエンドを使ったテスト

TODOアプリの完全なテストスイート

import { test, expect, Page } from '@playwright/test';

const mockTodos = [
  { id: 1, title: '買い物', completed: false },
  { id: 2, title: '掃除', completed: true },
  { id: 3, title: '料理', completed: false },
];

async function setupMockAPI(page: Page) {
  // GET /api/todos
  await page.route('**/api/todos', async (route) => {
    if (route.request().method() === 'GET') {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify(mockTodos),
      });
    } else if (route.request().method() === 'POST') {
      const body = route.request().postDataJSON();
      await route.fulfill({
        status: 201,
        contentType: 'application/json',
        body: JSON.stringify({ id: 4, ...body }),
      });
    }
  });

  // PUT /api/todos/:id
  await page.route('**/api/todos/*', async (route) => {
    if (route.request().method() === 'PUT') {
      const body = route.request().postDataJSON();
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify(body),
      });
    } else if (route.request().method() === 'DELETE') {
      await route.fulfill({ status: 204, body: '' });
    }
  });
}

test.describe('TODO App with Mocked Backend', () => {
  test.beforeEach(async ({ page }) => {
    await setupMockAPI(page);
    await page.goto('/todos');
  });

  test('display todo list', async ({ page }) => {
    await expect(page.locator('.todo-item')).toHaveCount(3);
    await expect(page.locator('.todo-item').first()).toContainText('買い物');
  });

  test('add new todo', async ({ page }) => {
    await page.fill('#new-todo', '勉強');
    await page.click('button.add');

    const response = await page.waitForResponse('**/api/todos');
    expect(response.status()).toBe(201);
  });

  test('show error on server failure', async ({ page }) => {
    // Override the POST handler with an error
    await page.route('**/api/todos', async (route) => {
      if (route.request().method() === 'POST') {
        await route.fulfill({
          status: 500,
          contentType: 'application/json',
          body: JSON.stringify({ error: 'Server Error' }),
        });
      } else {
        await route.continue();
      }
    });

    await page.fill('#new-todo', 'テスト');
    await page.click('button.add');
    await expect(page.locator('.error-message')).toBeVisible();
  });
});

まとめ

カテゴリ API 用途
監視 page.on('request') リクエストの観察
監視 page.on('response') レスポンスの観察
待機 waitForRequest() 特定リクエストの待機
待機 waitForResponse() 特定レスポンスの待機
モック route.fulfill() レスポンスをモック
変更 route.continue() リクエストを変更して転送
中断 route.abort() リクエストをブロック
HAR routeFromHAR() 記録と再生
オフライン context.setOffline() オフラインモード
APIテスト request.get() etc. ブラウザなしのAPIテスト

重要ポイント

  1. page.route() が中心 - Playwrightのネットワーク制御は page.route() を軸に、fulfill / continue / abort の3つの操作で構成される
  2. モックでテストを安定化 - バックエンドに依存せず、確定的なレスポンスでテストの信頼性を高める
  3. route.abort() で高速化 - 不要なリソースをブロックすることでテスト実行速度を改善
  4. HARで実データを活用 - 実際のAPIレスポンスを記録・再生することで、リアルなテストデータを維持
  5. APIテストも活用する - ブラウザテストとAPIテストを組み合わせて、効率的なテスト戦略を構築する

練習問題

基本

  1. page.route()route.fulfill() を使って、GETリクエストに対してモックレスポンスを返してください
  2. waitForResponse() を使って、特定のAPIレスポンスのステータスコードとボディを検証してください
  3. route.abort() を使って画像リクエストをブロックし、テスト速度の違いを比較してください

応用

  1. route.continue() を使ってリクエストヘッダーにカスタム認証トークンを追加してください
  2. エラーレスポンス(404, 500)をモックし、画面のエラーハンドリングをテストしてください
  3. context.setOffline(true) を使ってオフラインモードをテストし、再接続後のリカバリーを確認してください

チャレンジ

  1. routeFromHAR() を使ってHARファイルの記録と再生を実装してください
  2. request コンテキストを使ったCRUD APIテストスイートを作成し、ブラウザテストと組み合わせてください

参考リンク


次回予告

Day 7では、フィクスチャとPage Object Modelを学びます。テストコードの再利用性と保守性を大幅に向上させるPlaywrightのフィクスチャシステムと、Page Object Modelパターンによるテスト設計を習得しましょう。