PlaywrightでReactアプリのE2Eテスト: 完全ガイド

Shunku

エンドツーエンド(E2E)テストは、ブラウザ、API、データベースを含むフルスタックをテストし、ユーザーの視点からアプリケーションが正しく動作することを検証します。Playwrightは信頼性の高いテストを簡単に書ける現代的なE2Eテストフレームワークです。

なぜE2Eテストか?

flowchart TD
    subgraph Testing["テストピラミッド"]
        A[E2Eテスト] --> B[統合テスト]
        B --> C[ユニットテスト]
    end

    subgraph Coverage["テスト対象"]
        D[完全なユーザーフロー]
        E[コンポーネント連携]
        F[個別の関数]
    end

    A -.-> D
    B -.-> E
    C -.-> F

    style A fill:#ef4444,color:#fff
    style B fill:#f59e0b,color:#fff
    style C fill:#10b981,color:#fff

E2Eテストは:

  • 実際のユーザーワークフローをテスト
  • 統合の問題を検出
  • 重要なパスの動作を検証
  • 実際のブラウザで実行

Playwrightのセットアップ

インストール

npm init playwright@latest

これにより以下が作成されます:

  • playwright.config.ts - 設定ファイル
  • tests/ - テストディレクトリ
  • tests-examples/ - サンプルテスト

基本設定

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],

  // テスト前に開発サーバーを起動
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

最初のテストを書く

// e2e/home.spec.ts
import { test, expect } from '@playwright/test';

test('タイトルがある', async ({ page }) => {
  await page.goto('/');

  await expect(page).toHaveTitle(/My App/);
});

test('アバウトページにナビゲート', async ({ page }) => {
  await page.goto('/');

  await page.click('text=About');

  await expect(page).toHaveURL('/about');
  await expect(page.locator('h1')).toContainText('About Us');
});

テストの実行

# すべてのテストを実行
npx playwright test

# headedモードで実行(ブラウザを表示)
npx playwright test --headed

# 特定のファイルを実行
npx playwright test home.spec.ts

# デバッグモードで実行
npx playwright test --debug

# HTMLレポートを表示
npx playwright show-report

ロケーターとセレクター

Playwrightは要素を見つけるさまざまな方法を提供します:

推奨ロケーター

// ロールで(アクセシビリティ優先)
page.getByRole('button', { name: '送信' });
page.getByRole('heading', { level: 1 });
page.getByRole('textbox', { name: 'メール' });

// ラベルで
page.getByLabel('メールアドレス');

// プレースホルダーで
page.getByPlaceholder('メールを入力');

// テキストで
page.getByText('ようこそ');
page.getByText(/ようこそ/i); // 大文字小文字を区別しない

// テストIDで
page.getByTestId('submit-button');

CSSとXPath(非推奨)

// CSSセレクター
page.locator('.submit-btn');
page.locator('#email-input');

// XPath
page.locator('xpath=//button[@type="submit"]');

ロケーターのチェーン

// 特定のセクション内のボタンを見つける
const section = page.locator('section.user-profile');
const editButton = section.getByRole('button', { name: '編集' });

// または直接チェーン
page.locator('section.user-profile').getByRole('button', { name: '編集' });

アクション

クリックアクション

// 基本的なクリック
await page.click('button');
await page.getByRole('button').click();

// ダブルクリック
await page.dblclick('button');

// 右クリック
await page.click('button', { button: 'right' });

// 修飾キー付きクリック
await page.click('button', { modifiers: ['Shift'] });

フォーム操作

// テキスト入力
await page.fill('input[name="email"]', 'test@example.com');
await page.getByLabel('メール').fill('test@example.com');

// クリアして入力
await page.getByLabel('メール').clear();
await page.getByLabel('メール').fill('new@example.com');

// 遅延付き入力(実際のタイピングをシミュレート)
await page.getByLabel('メール').pressSequentially('test@example.com', { delay: 100 });

// オプション選択
await page.selectOption('select[name="country"]', 'jp');
await page.getByLabel('国').selectOption({ label: '日本' });

// チェック/チェック解除
await page.check('input[type="checkbox"]');
await page.uncheck('input[type="checkbox"]');
await page.getByLabel('利用規約に同意').check();

キーボードアクション

// キーを押す
await page.keyboard.press('Enter');
await page.keyboard.press('Control+a');

// テキストを入力
await page.keyboard.type('Hello');

// キーの組み合わせ
await page.keyboard.down('Shift');
await page.keyboard.press('ArrowDown');
await page.keyboard.up('Shift');

アサーション

ページアサーション

// URL
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveURL(/.*dashboard/);

// タイトル
await expect(page).toHaveTitle('ダッシュボード | My App');
await expect(page).toHaveTitle(/ダッシュボード/);

要素アサーション

const button = page.getByRole('button', { name: '送信' });

// 可視性
await expect(button).toBeVisible();
await expect(button).toBeHidden();

// 有効/無効
await expect(button).toBeEnabled();
await expect(button).toBeDisabled();

// テキストコンテンツ
await expect(button).toHaveText('送信');
await expect(button).toContainText('送');

// 属性
await expect(button).toHaveAttribute('type', 'submit');

// CSS
await expect(button).toHaveClass(/primary/);
await expect(button).toHaveCSS('background-color', 'rgb(0, 128, 0)');

// カウント
await expect(page.getByRole('listitem')).toHaveCount(5);

// 入力値
await expect(page.getByLabel('メール')).toHaveValue('test@example.com');

ソフトアサーション

アサーションが失敗してもテスト実行を継続:

await expect.soft(page.getByText('エラー')).not.toBeVisible();
await expect.soft(page.getByRole('button')).toBeEnabled();

// 上記アサーションが失敗してもテストは継続
await page.click('button');

ユーザーフローのテスト

ログインフロー

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('認証', () => {
  test('ユーザーがログインできる', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('メール').fill('user@example.com');
    await page.getByLabel('パスワード').fill('password123');
    await page.getByRole('button', { name: 'ログイン' }).click();

    // ダッシュボードにリダイレクト
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('おかえりなさい')).toBeVisible();
  });

  test('無効な資格情報でエラーを表示', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('メール').fill('user@example.com');
    await page.getByLabel('パスワード').fill('wrong-password');
    await page.getByRole('button', { name: 'ログイン' }).click();

    await expect(page.getByRole('alert')).toContainText('資格情報が無効です');
    await expect(page).toHaveURL('/login');
  });
});

ショッピングカートフロー

test('購入フローを完了', async ({ page }) => {
  // 商品を閲覧
  await page.goto('/products');
  await page.getByRole('link', { name: /ラップトップ/i }).click();

  // カートに追加
  await page.getByRole('button', { name: 'カートに追加' }).click();
  await expect(page.getByText('カートに追加しました')).toBeVisible();

  // カートへ移動
  await page.getByRole('link', { name: 'カート (1)' }).click();
  await expect(page).toHaveURL('/cart');

  // チェックアウトへ進む
  await page.getByRole('button', { name: 'チェックアウト' }).click();

  // 配送情報を入力
  await page.getByLabel('住所').fill('東京都渋谷区1-2-3');
  await page.getByLabel('市区町村').fill('渋谷区');
  await page.getByRole('button', { name: '続行' }).click();

  // 支払いを完了
  await page.getByLabel('カード番号').fill('4242424242424242');
  await page.getByRole('button', { name: '支払う' }).click();

  // 成功を確認
  await expect(page.getByRole('heading')).toContainText('注文完了');
});

Page Objectモデル

Page Objectパターンでテストを整理:

// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('メール');
    this.passwordInput = page.getByLabel('パスワード');
    this.submitButton = page.getByRole('button', { name: 'ログイン' });
    this.errorMessage = page.getByRole('alert');
  }

  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();
  }
}
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('ユーザーがログインできる', async ({ page }) => {
  const loginPage = new LoginPage(page);

  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');

  await expect(page).toHaveURL('/dashboard');
});

認証の処理

認証状態の保存

// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('認証', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('メール').fill('user@example.com');
  await page.getByLabel('パスワード').fill('password123');
  await page.getByRole('button', { name: 'ログイン' }).click();

  await expect(page).toHaveURL('/dashboard');

  // 認証状態を保存
  await page.context().storageState({ path: authFile });
});
// playwright.config.ts
export default defineConfig({
  projects: [
    // セットアッププロジェクト
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    // 認証が必要なテスト
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

APIテスト

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

test('APIがユーザーを返す', async ({ request }) => {
  const response = await request.get('/api/users');

  expect(response.ok()).toBeTruthy();

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

test('新しいユーザーを作成', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: {
      name: 'Alice',
      email: 'alice@example.com',
    },
  });

  expect(response.status()).toBe(201);

  const user = await response.json();
  expect(user.name).toBe('Alice');
});

ビジュアルテスト

test('ホームページのビジュアル', async ({ page }) => {
  await page.goto('/');

  // フルページスクリーンショット
  await expect(page).toHaveScreenshot('homepage.png');

  // 要素スクリーンショット
  await expect(page.getByRole('navigation')).toHaveScreenshot('nav.png');
});

スクリーンショットを更新:

npx playwright test --update-snapshots

ベストプラクティス

1. 信頼性の高いセレクターを使用

// 脆弱 - 構造に依存
page.locator('div > div > button');

// 堅牢 - アクセシビリティを使用
page.getByRole('button', { name: '送信' });

2. 要素を適切に待機

// 自動待機が組み込み
await page.getByRole('button').click(); // 自動的に待機

// 必要な時に明示的に待機
await page.waitForSelector('.dynamic-content');
await page.waitForLoadState('networkidle');

3. テストを分離

test.beforeEach(async ({ page }) => {
  // 各テスト前に状態をリセット
  await page.goto('/');
});

4. テストフックを使用

test.beforeAll(async () => {
  // すべてのテスト前に1回実行
});

test.afterEach(async ({ page }) => {
  // 各テスト後にクリーンアップ
});

まとめ

概念 説明
page.goto() URLにナビゲート
page.getByRole() アクセシビリティロールで検索
page.click() 要素をクリック
page.fill() 入力にテキストを入力
expect(page) ページアサーション
expect(locator) 要素アサーション

重要なポイント:

  • アクセシビリティベースのセレクター(getByRolegetByLabel)を使用
  • Playwrightには要素の自動待機が組み込み
  • 保守しやすいテストにはPage Objectモデルを使用
  • 認証状態を保存してテストを高速化
  • projectsで複数ブラウザでテストを実行
  • UIリグレッション検出にビジュアルテストを使用

E2Eテストはユーザーの視点からアプリケーションが動作するという信頼を与えます。Playwrightは現代的なAPIと強力な機能により、信頼性が高く高速なE2Eテストを簡単に書けるようにします。

参考文献