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 は中核的なユーザーフロー(登録/ログイン、主要業務フロー、決済)のみをカバーします。


関連ツール

ブラウザローカルツールを無料で試す →

#Playwright#E2E测试#自动化测试#前端#教程