Next.js: E2E テスト: Playwright

最終更新:2026-08-26

E2E テストは「エンドツーエンドのシミュレーション」であり、実際のユーザー操作を模倣します。アプリがブラウザでどの程度うまく動作するかを正確に教えてくれます。

1. 学習目標



2. テストエンジニアの実話

(1) 課題: 手動テストのカバレッジが不完全で本番インシデントが頻発

Diana は TaskFlow チームのテストエンジニアです。チームは 10,000 人以上のユーザーにサービスを提供する SaaS プラットフォームを維持しており、各リリース前に 3 人の QA エンジニアが 200 以上のテストケースを手動でテストする必要があり、これには丸 2 日かかります。

先週のリリースでは、「プロジェクト名の編集」機能は Chrome では正常に動作しましたが、Safari では入力フィールドにフォーカスできませんでした。QA チームは Chrome でのみテストしており、リリース後に 50 件以上の苦情を受け取りました。

手動テストの問題点は明らかです:

問題 影響
不完全なブラウザカバレッジ Chrome のみテスト。Safari と Firefox が省略された
長い開発サイクル 各リリースに 2 日間の手動テストが必要
一貫性のない結果 異なるテスターが異なる方法を使用
再現不能 バグ修正後に再テストが必要

(2) Playwright E2E ソリューション

Diana は Playwright を導入し、手動操作をコードに置き換えました:

TS
test('ユーザーがプロジェクトを作成できる', async ({ page }) => {
  await page.goto('/projects')
  await page.getByRole('button', { name: '新規プロジェクト' }).click()
  await page.getByPlaceholder('プロジェクト名').fill('EC アプリ')
  await page.getByRole('button', { name: '作成' }).click()
  await expect(page.getByText('EC アプリ')).toBeVisible()
})

(3) 効果

観点 手動テスト Playwright E2E
テスト所要時間 2 日間 15 分
ブラウザカバレッジ Chrome のみ Chrome + Firefox + Safari + Edge
復元サイクル 毎回完全復元 毎回増分復元 + CI 自動
バグ流出率 15% < 2%
テスト再現性 低い(人的ばらつき) 高い(100% 一貫性)


3. Playwright 環境のセットアップ

Playwright は Microsoft がメンテナンスするクロスブラウザ自動テストフレームワークで、Chromium、Firefox、WebKit をサポートしています。

100%
graph TB
    A[playwright.config.ts] --> B[webServer 設定]
    A --> C[ブラウザ設定]
    A --> D[テストディレクトリ]
    B --> E[自動起動 npm run dev]
    B --> F[ポート 3000 のリッスン可能]
    C --> G[Chromium / Firefox / WebKit]
    C --> H[ビューポート 1280x720]
    
    style A fill:#cce5ff
    style B fill:#d4edda
設定ファイル 目的 主要オプション
playwright.config.ts E2E 主要設定 webServer 自動ライフサイクル管理
playwright/index.html コンポーネントテストポータル ct.target 設定
.github/workflows/playwright.yml CI 統合 npx playwright install --with-deps

(1) インストールと初期化

BASH
npm init playwright@latest
# 選択:
#   ✓ TypeScript
#   ✓ E2E tests
#   ✓ GitHub Actions ワークフローを追加
#   ✓ ブラウザをインストール (Chromium, Firefox, WebKit)

(2) playwright.config.ts のコア設定

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'],
    ['json', { outputFile: 'playwright-report/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'] }
    }
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 30000
  }
})

(3) ディレクトリ構造

TEXT 📖 参照専用
├── e2e/
│   ├── auth.setup.ts        # 認証の前処理
│   ├── models/
│   │   ├── LoginPage.ts     # ログインページ POM
│   │   ├── ProjectPage.ts   # プロジェクトページ POM
│   │   └── TaskPage.ts      # タスクページ POM
│   ├── navigation.spec.ts   # ナビゲーションテスト
│   ├── auth.spec.ts         # 認証テスト
│   ├── projects.spec.ts     # プロジェクト CRUD
│   └── visual.spec.ts       # ビジュアルリグレッション
├── playwright.config.ts
├── playwright-report/
└── test-results/

▶ サンプル: Playwright の動作確認

TS
// e2e/example.spec.ts
import { test, expect } from '@playwright/test'

test('ホームページのタイトルが正しい', async ({ page }) => {
  await page.goto('/')
  await expect(page).toHaveTitle(/TaskFlow/)
  await expect(page.locator('h1')).toContainText('Welcome')
})
BASH
npx playwright test --project=chromium --headed
💻 出力:

TEXT 📖 参照専用
Running 1 test using 1 worker
  ✓ e2e/example.spec.ts:3:1 › ホームページのタイトルが正しい (2.3s)

Output:

TEXT 📖 参照専用
Running 1 test using 1 worker
  ✓ e2e/example.spec.ts:3:1 › ホームページのタイトルが正しい (2.3s)

  1 passed (2.3s)


4. Page Object Model (POM) デザインパターン

POM はページ操作ロジックを個別のクラスにカプセル化し、テストコードは「何をするか」だけに集中し、「どうやってするか」は気にしません。

100%
graph TB
    A[テストケース] --> B[Page Object レイヤー]
    B --> C[LoginPage]
    B --> D[ProjectsPage]
    B --> E[TaskPage]
    C --> F[ブラウザ API]
    D --> F
    E --> F
    
    style B fill:#cce5ff
    style C fill:#d4edda
    style D fill:#d4edda
    style E fill:#d4edda

(1) LoginPage POM の実装

TS
// e2e/models/LoginPage.ts
import { type Page, type 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.getByPlaceholder('メールアドレスを入力')
    this.passwordInput = page.getByPlaceholder('パスワードを入力')
    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)
  }

  async expectLoggedIn() {
    await expect(this.page).toHaveURL(/dashboard/)
  }
}

(2) ProjectsPage POM の実装

TS
// e2e/models/ProjectsPage.ts
import { type Page, type Locator } from '@playwright/test'

export class ProjectsPage {
  readonly page: Page
  readonly newProjectButton: Locator
  readonly projectNameInput: Locator
  readonly createButton: Locator
  readonly projectList: Locator
  readonly searchInput: Locator

  constructor(page: Page) {
    this.page = page
    this.newProjectButton = page.getByRole('button', { name: '新規プロジェクト' })
    this.projectNameInput = page.getByPlaceholder('プロジェクト名')
    this.createButton = page.getByRole('button', { name: '作成' })
    this.projectList = page.getByTestId('project-list')
    this.searchInput = page.getByPlaceholder('検索')
  }

  async goto() {
    await this.page.goto('/projects')
  }

  async createProject(name: string) {
    await this.newProjectButton.click()
    await this.projectNameInput.fill(name)
    await this.createButton.click()
  }

  async searchProject(name: string) {
    await this.searchInput.fill(name)
  }

  async expectProjectVisible(name: string) {
    await expect(this.projectList).toContainText(name)
  }

  async openProject(name: string) {
    await this.page.getByRole('link', { name }).first().click()
  }
}

▶ サンプル: POM 駆動テスト

Output:

TEXT 📖 参照専用
Defines TypeScript type(s): Page, Locator.
TS
// e2e/projects.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from './models/LoginPage'
import { ProjectsPage } from './models/ProjectsPage'

test.describe('プロジェクト管理', () => {
  let loginPage: LoginPage
  let projectsPage: ProjectsPage

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page)
    projectsPage = new ProjectsPage(page)
    
    await loginPage.goto()
    await loginPage.login('alice@example.com', 'password123')
    await loginPage.expectLoggedIn()
  })

  test('新規プロジェクトを作成する', async () => {
    await projectsPage.goto()
    await projectsPage.createProject('EC ダッシュボード')
    await projectsPage.expectProjectVisible('EC ダッシュボード')
  })

  test('既存のプロジェクトを検索する', async () => {
    await projectsPage.goto()
    await projectsPage.searchProject('EC')
    await projectsPage.expectProjectVisible('EC ダッシュボード')
  })
})

Output:

TEXT 📖 参照専用
TypeScript code executed successfully.


5. クリティカルパステスト

クリティカルパスはユーザーが最も頻繁に使用するビジネスワークフローであり、優先的にテストする必要があります。

(1) 認証フローテスト

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

test.describe('認証フロー', () => {
  test('未認証ユーザーをログインにリダイレクトする', async ({ page }) => {
    await page.goto('/dashboard')
    await expect(page).toHaveURL(/login/)
  })

  test('空フォームでバリデーションエラーを表示する', async ({ page }) => {
    await page.goto('/login')
    await page.getByRole('button', { name: 'ログイン' }).click()
    await expect(page.getByText(/メールアドレスを入力してください/i)).toBeVisible()
    await expect(page.getByText(/パスワードを入力してください/i)).toBeVisible()
  })

  test('ログイン成功でダッシュボードにリダイレクトする', async ({ page }) => {
    await page.goto('/login')
    await page.getByPlaceholder('メールアドレスを入力').fill('alice@example.com')
    await page.getByPlaceholder('パスワードを入力').fill('correct-password')
    await page.getByRole('button', { name: 'ログイン' }).click()
    await expect(page).toHaveURL(/dashboard/)
    await expect(page.getByText(/おかえりなさい、Alice/i)).toBeVisible()
  })

  test('ログアウトでセッションをクリアする', async ({ page }) => {
    await page.goto('/login')
    await page.getByPlaceholder('メールアドレスを入力').fill('alice@example.com')
    await page.getByPlaceholder('パスワードを入力').fill('correct-password')
    await page.getByRole('button', { name: 'ログイン' }).click()
    await page.getByRole('button', { name: /終了/i }).click()
    await expect(page).toHaveURL(/login/)
  })
})

(2) フォーム送信テスト

TS
// e2e/task-creation.spec.ts
import { test, expect } from '@playwright/test'

test.describe('タスク作成フロー', () => {
  test.beforeEach(async ({ page }) => {
    // ログインしてプロジェクトに移動
    await page.goto('/login')
    await page.getByPlaceholder('メールアドレスを入力').fill('alice@example.com')
    await page.getByPlaceholder('パスワードを入力').fill('password123')
    await page.getByRole('button', { name: 'ログイン' }).click()
    await page.goto('/projects/p1')
  })

  test('全フィールドでタスクを作成する', async ({ page }) => {
    await page.getByRole('button', { name: 'タスクを追加' }).click()
    await page.getByPlaceholder('タスクタイトル').fill('ユーザー認証を実装')
    await page.getByLabel('優先度').selectOption('高')
    await page.getByLabel('担当者').selectOption('Bob')
    await page.getByRole('button', { name: '保存' }).click()
    await expect(page.getByText('ユーザー認証を実装')).toBeVisible()
  })

  test('空のタイトルでエラーを表示する', async ({ page }) => {
    await page.getByRole('button', { name: 'タスクを追加' }).click()
    await page.getByRole('button', { name: '保存' }).click()
    await expect(page.getByText(/タイトルは必須です。/i)).toBeVisible()
  })
})

(3) ナビゲーションとルーティングのテスト

TS
// e2e/navigation.spec.ts
import { test, expect } from '@playwright/test'

test.describe('ナビゲーションフロー', () => {
  test('サイドバーリンクが正しくナビゲートする', async ({ page }) => {
    await page.goto('/login')
    await page.getByPlaceholder('メールアドレスを入力').fill('alice@example.com')
    await page.getByPlaceholder('パスワードを入力').fill('password123')
    await page.getByRole('button', { name: 'ログイン' }).click()
    
    await page.getByRole('link', { name: 'プロジェクト' }).click()
    await expect(page).toHaveURL(/\/projects/)
    
    await page.getByRole('link', { name: 'ダッシュボード' }).click()
    await expect(page).toHaveURL(/\/dashboard/)
    
    await page.getByRole('link', { name: '設定' }).click()
    await expect(page).toHaveURL(/\/settings/)
  })

  test('パンくずが現在の場所を表示する', async ({ page }) => {
    await page.goto('/login')
    await page.getByPlaceholder('メールアドレスを入力').fill('alice@example.com')
    await page.getByPlaceholder('パスワードを入力').fill('password123')
    await page.getByRole('button', { name: 'ログイン' }).click()
    await page.goto('/projects/p1/tasks/t1')
    await expect(page.getByTestId('breadcrumb')).toContainText([
      /プロジェクト/, /プロジェクト 1/, /タスク 1/
    ])
  })
})


6. ビジュアルリグレッションテスト

ビジュアルリグレッションテストは UI スタイリングの微妙な変更を検出します。Playwright は toHaveScreenshot() を通じてピクセルレベルの比較を可能にします。

100%
graph LR
    A[テスト実行] --> B[現在の状態のスクリーンショットを撮影]
    B --> C{参照スクリーンショットと比較}
    C -->|一致| D[テスト合格]
    C -->|差分 > しきい値| E[テスト失敗]
    E --> F[差分レポートを生成]
    
    style A fill:#cce5ff
    style C fill:#fff3cd
    style D fill:#d4edda
    style E fill:#f8d7da
オプション 説明 推奨値
maxDiffPixels 許容される最大差分ピクセル数 100
maxDiffPixelRatio 最大差分比率 0.01
threshold ピクセル比較しきい値 0.2
animations アニメーションを無効化 'disabled'
stylePath 追加の CSS オーバーライド ランダム要素を非表示

(1) ビジュアルテストの設定

TS
// playwright.config.ts 抜粋
export default defineConfig({
  use: {
    screenshot: 'only-on-failure',
    viewport: { width: 1280, height: 720 }
  },
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 100,
      animations: 'disabled'
    }
  }
})

(2) 参照スクリーンショットの生成

BASH
# 初回実行時にベースラインスクリーンショットを生成
npx playwright test --update-snapshots

# 以降の実行でベースラインと比較
npx playwright test

▶ サンプル: ビジュアルリグレッションテスト

Output:

TEXT 📖 参照専用
Running 5 tests using 1 worker

  ✓  navigation.spec.ts:3:1 › navigates to about page (1.2s)
  ✓  navigation.spec.ts:10:1 › displays product list (0.8s)
  ✓  form.spec.ts:5:1 › submits contact form (1.5s)

  3 passed (3.5s)
Running 5 tests using 1 worker

  ✓  navigation.spec.ts:3:1 › navigates to about page (1.2s)
  ✓  navigation.spec.ts:10:1 › displays product list (0.8s)
  ✓  form.spec.ts:5:1 › submits contact form (1.5s)

  3 passed (3.5s)
TS
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test'

test.describe('ビジュアルリグレッション', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login')
    await page.getByPlaceholder('メールアドレスを入力').fill('alice@example.com')
    await page.getByPlaceholder('パスワードを入力').fill('password123')
    await page.getByRole('button', { name: 'ログイン' }).click()
  })

  test('ダッシュボードページがスナップショットと一致する', async ({ page }) => {
    await page.goto('/dashboard')
    await page.waitForLoadState('networkidle')
    await expect(page).toHaveScreenshot('dashboard.png', {
      fullPage: true
    })
  })

  test('プロジェクトリストがスナップショットと一致する', async ({ page }) => {
    await page.goto('/projects')
    await page.waitForSelector('[data-testid="project-list"]')
    await expect(page).toHaveScreenshot('projects.png')
  })

  test('タスク詳細がスナップショットと一致する', async ({ page }) => {
    await page.goto('/projects/p1/tasks/t1')
    await page.waitForLoadState('networkidle')
    await expect(page).toHaveScreenshot('task-detail.png', {
      mask: [page.locator('[data-testid="timestamp"]')]
    })
  })
})

Output:

TEXT 📖 参照専用
TypeScript code executed successfully.


7. API モックとリクエスト傍受

Playwright の page.route() はブラウザレベルでネットワークリクエストを傍受し、実際のバックエンドなしでフロントエンド/バックエンドの相互作用をテストできます。

(1) ルート傍受の基本

TS
// グローバル傍受
await page.route('**/api/**', async route => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([{ id: 1, title: 'モックタスク' }])
  })
})

// 特定 URL の傍受
await page.route('https://api.example.com/products*', async route => {
  const response = await route.fetch() // 実際の API にアクセス
  const body = await response.json()
  body.push({ id: 999, name: '注入された製品' })
  await route.fulfill({ response, body: JSON.stringify(body) })
})

(2) API モックパターンの比較

パターン 方法 適用シナリオ
完全モック route.fulfill() バックエンドが準備できていない場合のフロントエンドテスト
エージェント変更 route.fetch() + 変更 テストデータ/エラーの注入
プロキシパススルー route.fetch() + route.fulfill({ response }) リクエストを監視するがデータは変更しない
リクエスト中断 route.abort() オフライン状態のテスト

▶ サンプル: API モックを使用した完全なテスト

Output:

TEXT 📖 参照専用
TypeScript code executed successfully.
TS
// e2e/api-mock.spec.ts
import { test, expect } from '@playwright/test'

test.describe('API モックシナリオ', () => {
  test('データがない場合に空の状態を表示する', async ({ page }) => {
    await page.route('**/api/projects', async route => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify([])
      })
    })
    
    await page.goto('/projects')
    await expect(page.getByText(/まだプロジェクトがありません/i)).toBeVisible()
  })

  test('500 エラーを適切に処理する', async ({ page }) => {
    await page.route('**/api/projects', async route => {
      await route.fulfill({
        status: 500,
        contentType: 'application/json',
        body: JSON.stringify({ error: 'サーバーエラー' })
      })
    })
    
    await page.goto('/projects')
    await expect(page.getByText(/読み込みに失敗しました/i)).toBeVisible()
  })

  test('読み込み状態を表示してからデータを表示する', async ({ page }) => {
    // レスポンスを遅延させて読み込み状態をテスト
    await page.route('**/api/projects', async route => {
      await new Promise(resolve => setTimeout(resolve, 1000))
      await route.fulfill({
        status: 200,
        body: JSON.stringify([
          { id: 'p1', name: 'モックプロジェクト', taskCount: 5 }
        ])
      })
    })
    
    await page.goto('/projects')
    await expect(page.getByTestId('loading-skeleton')).toBeVisible()
    await expect(page.getByText('モックプロジェクト')).toBeVisible({ timeout: 5000 })
  })

  test('ネットワークオフラインシナリオ', async ({ page }) => {
    await page.route('**/api/**', async route => {
      await route.abort('internetdisconnected')
    })
    
    await page.goto('/projects')
    await expect(page.getByText(/ネットワーク接続に失敗しました/i)).toBeVisible()
  })
})

Output:

TEXT 📖 参照専用
TypeScript code executed successfully.


8. CI 統合: GitHub Actions

Playwright は GitHub Actions とシームレスに統合され、プッシュのたびに自動的に E2E テストを実行します。

YAML
# .github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest

    strategy:
      matrix:
        browser: [chromium, firefox, webkit]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: 依存関係をインストール
        run: npm ci

      - name: Playwright ブラウザをキャッシュ
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}

      - name: Playwright ブラウザをインストール
        run: npx playwright install --with-deps ${{ matrix.browser }}

      - name: Playwright テストを実行
        run: npx playwright test --project=${{ matrix.browser }}

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report-${{ matrix.browser }}
          path: playwright-report/
          retention-days: 7


9. 完全なサンプル: TaskFlow アプリケーション E2E スイート

TS
// e2e/taskflow-e2e.spec.ts
// ============================================
// 総合 E2E テスト: TaskFlow アプリケーションの完全なユーザーフロー
// ============================================
import { test, expect, type Page } from '@playwright/test'

// --- ユーティリティ関数: 認証状態を設定 ---
async function setupAuth(page: Page) {
  await page.goto('/login')
  await page.getByPlaceholder('メールアドレスを入力').fill('alice@taskflow.io')
  await page.getByPlaceholder('パスワードを入力').fill('Password123!')
  await page.getByRole('button', { name: 'ログイン' }).click()
  await expect(page).toHaveURL(/dashboard/)
}

test.describe('TaskFlow E2E スイート', () => {
  test.beforeEach(async ({ page }) => {
    await setupAuth(page)
  })

  // --- 1. ダッシュボード読み込み ---
  test('ダッシュボードに主要指標を表示する', async ({ page }) => {
    await page.goto('/dashboard')
    await expect(page.getByTestId('total-projects')).toBeVisible()
    await expect(page.getByTestId('total-tasks')).toBeVisible()
    await expect(page.getByTestId('team-members')).toBeVisible()
    
    const projectCount = await page.getByTestId('total-projects').textContent()
    expect(Number(projectCount)).toBeGreaterThan(0)
  })

  // --- 2. プロジェクト CRUD フロー ---
  test('プロジェクトの完全なライフサイクル', async ({ page }) => {
    // 作成
    await page.goto('/projects')
    await page.getByRole('button', { name: '新規プロジェクト' }).click()
    await page.getByPlaceholder('プロジェクト名').fill('E2E テストプロジェクト')
    await page.getByPlaceholder('説明').fill('Playwright によって作成')
    await page.getByRole('button', { name: '作成' }).click()
    await expect(page.getByText('E2E テストプロジェクト')).toBeVisible()

    // 編集
    await page.getByRole('button', { name: /編集/i }).click()
    await page.getByPlaceholder('プロジェクト名').fill('E2E テストプロジェクト v2')
    await page.getByRole('button', { name: '保存' }).click()
    await expect(page.getByText('E2E テストプロジェクト v2')).toBeVisible()

    // 削除
    await page.getByRole('button', { name: /削除/i }).click()
    await page.getByRole('button', { name: /確認/i }).click()
    await expect(page.getByText('E2E テストプロジェクト v2')).not.toBeVisible()
  })

  // --- 3. タスク管理 ---
  test('タスクのドラッグ & ドロップでステータス変更', async ({ page }) => {
    await page.goto('/projects/p1')
    
    // タスクを作成
    await page.getByRole('button', { name: 'タスクを追加' }).click()
    await page.getByPlaceholder('タスクタイトル').fill('Playwright テストのセットアップ')
    await page.getByText('TODO').click()
    await page.getByRole('button', { name: '保存' }).click()
    
    // タスクが TODO カラムに表示されることを確認
    await expect(page.getByTestId('column-todo'))
      .toContainText('Playwright テストのセットアップ')
  })

  // --- 4. 検索機能 ---
  test('検索でプロジェクトを正しくフィルタリングする', async ({ page }) => {
    await page.goto('/projects')
    await page.getByPlaceholder('検索').fill('マーケティング')
    await page.waitForTimeout(300) // デバウンス
    const items = page.getByTestId('project-item')
    const count = await items.count()
    for (let i = 0; i < count; i++) {
      await expect(items.nth(i)).toContainText(/マーケティング/i)
    }
  })

  // --- 5. レスポンシブレイアウト ---
  test.describe('レスポンシブデザイン', () => {
    test('モバイルビューポートでサイドバーが折りたたまれる', async ({ page }) => {
      await page.setViewportSize({ width: 375, height: 812 })
      await page.goto('/dashboard')
      await expect(page.getByTestId('sidebar')).not.toBeVisible()
      await page.getByRole('button', { name: /メニュー/i }).click()
      await expect(page.getByTestId('sidebar')).toBeVisible()
    })
  })

  // --- 6. API モックテスト ---
  test('API エラーを適切に処理する', async ({ page }) => {
    await page.route('**/api/dashboard/metrics', async route => {
      await route.fulfill({
        status: 503,
        body: JSON.stringify({ error: 'サービス利用不可' })
      })
    })
    
    await page.goto('/dashboard')
    await expect(page.getByText(/サービスが一時的に利用できません/i)).toBeVisible()
  })
})
💻 出力:

TEXT 📖 参照専用
Running 8 tests using 2 workers
  ✓ e2e/taskflow-e2e.spec.ts (8 tests) 34.2s
  ✓ chromium  | 8 passed (34.2s)
  ✓ firefox   | 8 passed (38.1s)
  ✓ webkit    | 8 passed (41.5s)

❓ よくある質問

Q Playwright と Cypress の違いは何ですか?
A Playwright は Microsoft がメンテナンスしており(45k⭐)、複数のブラウザ(Chromium/Firefox/WebKit)をサポートし、CDP プロトコルを使用してブラウザを直接制御するため高速です。Cypress はページに JavaScript を注入して実行する必要があり、Chromium ベースのブラウザのみをサポートします。Playwright の webServer 設定は Next.js の npm run dev 自動起動シナリオにも適しています。
Q ログインが必要なページをテストするにはどうすればよいですか?
A Playwright の storageState 機能を使用することをお勧めします: auth.setup.ts でログイン後、Cookie と localStorage を storageState.json に保存し、他のテストの use 設定でそのファイルを参照することで、各テストで繰り返しログインする必要がなくなります。
Q ビジュアルリグレッションテストのベースラインスクリーンショットはどこに保持すべきですか?
A ベースラインスクリーンショットはコードの一部として Git リポジトリ(e2e/__screenshots__/)にコミットする必要があります。CI システムは実行時に自動的に比較します。.gitattributes を使用してスクリーンショットファイルに binary のラベルを付けることを忘れないでください。
Q E2E テストで page.route() と MSW のどちらを選ぶべきですか?
A page.route() はブラウザレベルでリクエストを傍受し、追加の依存関係が不要なため、一時的なモックに適しています。MSW は Service Worker の登録が必要で、コンポーネントテスト(Vitest)に適しています。E2E テストでは、外部依存を減らすためにデフォルトで page.route() を推奨します。
Q CI で Playwright テストの実行が遅い場合の対策は?
A (1) workers: 1 を使用してリソース競合を回避; (2) ブラウザバイナリをキャッシュ(~/.cache/ms-playwright); (3) CI では --project=chromium で重要なブラウザのみ実行; (4) 並列化: テストを複数のジョブに分割します。
Q テスト内のランダムデータ(タイムスタンプやデータベース ID など)はどのように処理すべきですか?
A Playwright の mask オプションを使用してビジュアルテストで動的要素を非表示にします: toHaveScreenshot({ mask: [page.locator('[data-testid="timestamp"]')] })。テキストアサーションには固定値ではなく正規表現パターンを使用します。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐): goto()login(email, password)expectLoggedIn() の 3 つのメソッドを含む LoginPage Page Object を作成し、この POM を使用するテストケースを記述してください。

  2. 応用問題 (⭐⭐): Next.js アプリケーションの完全な「作成-編集-削除」(CRUD) ワークフローに対して 5 つの E2E テストを記述し、フォームバリデーションエラーと API エラーシナリオのための page.route() モックを含めてください。

  3. 発展問題 (⭐⭐⭐): 完全なビジュアルリグレッションテストスイートを実装してください: (1) 5 つのキーページのベースラインスクリーンショットを生成; (2) expect.toHaveScreenshotmaxDiffPixels しきい値を設定; (3) プルリクエストでスクリーンショットを自動比較し、差分レポートをコメントとして追加する CI スクリプトを作成します。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%