Playwright E2E テスト実践ガイド:テストケース作成から CI 統合まで
なぜ Cypress や Selenium ではなく Playwright なのか
3 つのプロジェクトで Selenium、Cypress、Playwright をそれぞれ使って E2E テストを実装しました。Selenium は最も苦痛でした——WebDriver プロトコルは遅く、待機ロジックは sleep 頼り、ブラウザバージョンの一致は爆弾処理のようでした。Cypress は中規模プロジェクトで半年以上問題なく動いていましたが、テスト対象ページで 3 つの window.open が使われた途端に停止——Cypress はマルチタブをサポートしていないのです。
Playwright はこれらの問題点を解決します。Google を離れた Puppeteer チームが開発し、Chromium、Firefox、WebKit をネイティブサポート。API 設計の一貫性が際立ちます。最大の特徴は自動待機——クリック前に要素が表示され操作可能になるまで自動的に待つため、手動で waitForSelector を書く必要がありません。
この記事は API の翻訳ではありません。実際のプロジェクトで最も使用されるパターンと、0 から 50 テストケースまでのチーム経験に焦点を当てます。
セットアップとプロジェクト構造
npm init playwright@latest
生成されるプロジェクト構造:
e2e/
├── tests/
│ ├── example.spec.ts
│ ├── login.spec.ts
│ └── dashboard.spec.ts
├── fixtures/
│ └── auth.setup.ts
├── pages/
│ ├── LoginPage.ts
│ └── DashboardPage.ts
├── utils/
│ └── helpers.ts
└── playwright.config.ts
コア設定
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e/tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 3 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results.json' }],
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
重要なポイント:
fullyParallel: true— 全テストが完全並列実行。CI で 50 ケースが 2-3 分で完了trace: 'on-first-retry'— 失敗時に自動トレース記録。後からビデオのように再生可能webServer— 開発サーバーを自動起動
ロケーター戦略:安定性の第一防衛線
不安定なロケーターは E2E テスト最大の敵です。DOM 構造の変更で全テストが失敗します。Playwright は優先度順に多層的なロケーター戦略を提供します:
| 優先度 | ロケーター | 例 | 安定性 |
|---|---|---|---|
| 最高 | getByRole |
page.getByRole('button', { name: '送信' }) |
✅ 非常に安定 |
| 高 | getByLabel |
page.getByLabel('メールアドレス') |
✅ 安定 |
| 高 | getByPlaceholder |
page.getByPlaceholder('メールを入力') |
✅ 安定 |
| 高 | getByText |
page.getByText('注文詳細') |
✅ 安定 |
| 中 | getByTestId |
page.getByTestId('submit-btn') |
✅ 安定 |
| 中 | locator (CSS) |
page.locator('.login-form .submit') |
⚠️ 壊れやすい |
| 低 | locator (XPath) |
page.locator('//div[@class="btn"]') |
❌ 非常に壊れやすい |
実践比較
// ❌ 悪い:CSS クラス変更でテストが失敗
await page.locator('#app > div > form > button.btn-primary').click();
// ✅ 良い:アクセシビリティセマンティクスに基づく、UI フレームワーク非依存
await page.getByRole('button', { name: 'ログイン' }).click();
// ✅ 良い:アクセシブルなラベルに基づく
await page.getByLabel('メールアドレス').fill('test@example.com');
await page.getByPlaceholder('パスワードを入力').fill('Secret123');
フィルタリングとチェーン
const item = page.getByRole('listitem').filter({ hasText: '注文 #2024' });
page.getByText('削除', { exact: true }); // 完全一致のみ
page.getByText('削除'); // 部分一致
アサーション戦略:すべてを assert しない
test('ユーザーがログインしてダッシュボードに遷移', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('メール').fill('admin@example.com');
await page.getByLabel('パスワード').fill('password123');
await page.getByRole('button', { name: 'ログイン' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('お帰りなさい、Admin')).toBeVisible();
await expect(page.getByText('ログイン失敗')).not.toBeVisible();
});
アサーションクイックリファレンス
await expect(page).toHaveTitle(/ダッシュボード/);
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByText('保存完了')).toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page.getByLabel('メール')).toHaveValue('admin@example.com');
await expect(page.getByRole('button', { name: '送信' })).toBeDisabled();
ネットワークインターセプトとモック
test('ユーザー一覧表示——バックエンドデータをモック', async ({ page }) => {
await page.route('**/api/users**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{ id: 1, name: '田中太郎', email: 'tanaka@example.com' },
{ id: 2, name: '鈴木花子', email: 'suzuki@example.com' },
],
total: 2,
}),
});
});
await page.goto('/users');
await expect(page.getByText('田中太郎')).toBeVisible();
});
ネットワークエラーのシミュレーション
// 500 エラー
await page.route('**/api/submit', async (route) => {
await route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Internal Server Error' }),
});
});
// ネットワークタイムアウト
await page.route('**/api/slow-endpoint', async (route) => {
await route.abort('timedout');
});
認証状態の再利用
// e2e/tests/auth.setup.ts
import { test as setup } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../.auth/user.json');
setup('認証して状態を保存', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('メール').fill('admin@example.com');
await page.getByLabel('パスワード').fill('password123');
await page.getByRole('button', { name: 'ログイン' }).click();
await expect(page).toHaveURL('/dashboard');
await page.context().storageState({ path: authFile });
});
以降のテストは user.json から認証状態を即座に読み込みます。
Page Object パターン
// e2e/pages/LoginPage.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(readonly page: Page) {
this.emailInput = page.getByLabel('メール');
this.passwordInput = page.getByLabel('パスワード');
this.submitButton = page.getByRole('button', { name: 'ログイン' });
this.errorMessage = page.getByTestId('login-error');
}
async goto() { await this.page.goto('/login'); }
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
CI/CD 統合
name: E2E Tests
on:
pull_request:
branches: [main]
jobs:
e2e:
timeout-minutes: 15
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: 依存関係のインストール
run: npm ci
- name: Playwright ブラウザのインストール
run: npx playwright install --with-deps chromium
- name: E2E テスト実行
run: npx playwright test --shard=${{ matrix.shard }}/3
- name: テストレポートのアップロード
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ matrix.shard }}
path: playwright-report/
retention-days: 7
デバッグガイド
Trace Viewer
npx playwright test --trace on
npx playwright show-trace test-results/example-LoginPage/trace.zip
便利なコマンド
npx playwright test --last-failed # 失敗したテストのみ再実行
npx playwright test tests/login.spec.ts:42 # 特定の行を実行
npx playwright test --headed # ブラウザを表示
npx playwright test --headed --debug # ステップ実行
よくある質問
Q:Playwright と Cypress、どちらを選ぶべき?
A:新規プロジェクトなら Playwright を推奨。Cypress はコミュニティの成熟度と Dashboard サービスに強みがありますが、技術面——マルチタブ対応、マルチブラウザ、自動待機、並列実行——では Playwright が優位です。
Q:E2E テストのカバレッジはどの程度必要?
A:テストピラミッドの比率——単体テスト 70%、統合テスト 20%、E2E テスト 10%。E2E は中核的なユーザーフロー(登録/ログイン、主要業務フロー、決済)のみをカバーします。
関連ツール
- JSON フォーマッター — API モックデータの整形
- テキスト差分 — テスト出力と期待値の比較
- Base64 エンコード/デコード — スクリーンショットとファイルアップロードのテスト
ブラウザローカルツールを無料で試す →