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テスト |
重要ポイント
- page.route() が中心 - Playwrightのネットワーク制御は page.route() を軸に、fulfill / continue / abort の3つの操作で構成される
- モックでテストを安定化 - バックエンドに依存せず、確定的なレスポンスでテストの信頼性を高める
- route.abort() で高速化 - 不要なリソースをブロックすることでテスト実行速度を改善
- HARで実データを活用 - 実際のAPIレスポンスを記録・再生することで、リアルなテストデータを維持
- APIテストも活用する - ブラウザテストとAPIテストを組み合わせて、効率的なテスト戦略を構築する
練習問題
基本
page.route()とroute.fulfill()を使って、GETリクエストに対してモックレスポンスを返してくださいwaitForResponse()を使って、特定のAPIレスポンスのステータスコードとボディを検証してくださいroute.abort()を使って画像リクエストをブロックし、テスト速度の違いを比較してください
応用
route.continue()を使ってリクエストヘッダーにカスタム認証トークンを追加してください- エラーレスポンス(404, 500)をモックし、画面のエラーハンドリングをテストしてください
context.setOffline(true)を使ってオフラインモードをテストし、再接続後のリカバリーを確認してください
チャレンジ
routeFromHAR()を使ってHARファイルの記録と再生を実装してくださいrequestコンテキストを使ったCRUD APIテストスイートを作成し、ブラウザテストと組み合わせてください
参考リンク
次回予告
Day 7では、フィクスチャとPage Object Modelを学びます。テストコードの再利用性と保守性を大幅に向上させるPlaywrightのフィクスチャシステムと、Page Object Modelパターンによるテスト設計を習得しましょう。