Next.js: E2E テスト: Playwright
最終更新:2026-08-26
E2E テストは「エンドツーエンドのシミュレーション」であり、実際のユーザー操作を模倣します。アプリがブラウザでどの程度うまく動作するかを正確に教えてくれます。
1. 学習目標
- Playwright の設定と Next.js 開発サーバーの自動起動(webServer 設定)
- Page Object Model (POM) を使用した保守性の高いテストコードの設計
- クリティカルパステストの開発: ページナビゲーション、フォーム送信、認証フロー
toHaveScreenshot()を使用したビジュアルリグレッションテストpage.route()による API リクエストの傍受を使用した分離テストの実装- GitHub Actions CI パイプラインへの Playwright の統合
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 を導入し、手動操作をコードに置き換えました:
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 をサポートしています。
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) インストールと初期化
npm init playwright@latest
# 選択:
# ✓ TypeScript
# ✓ E2E tests
# ✓ GitHub Actions ワークフローを追加
# ✓ ブラウザをインストール (Chromium, Firefox, WebKit)
(2) 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'],
['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) ディレクトリ構造
├── 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 の動作確認
// 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')
})
npx playwright test --project=chromium --headed
Running 1 test using 1 worker
✓ e2e/example.spec.ts:3:1 › ホームページのタイトルが正しい (2.3s)
Output:
Running 1 test using 1 worker
✓ e2e/example.spec.ts:3:1 › ホームページのタイトルが正しい (2.3s)
1 passed (2.3s)
4. Page Object Model (POM) デザインパターン
POM はページ操作ロジックを個別のクラスにカプセル化し、テストコードは「何をするか」だけに集中し、「どうやってするか」は気にしません。
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 の実装
// 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 の実装
// 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:
Defines TypeScript type(s): Page, Locator.
// 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:
TypeScript code executed successfully.
5. クリティカルパステスト
クリティカルパスはユーザーが最も頻繁に使用するビジネスワークフローであり、優先的にテストする必要があります。
(1) 認証フローテスト
// 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) フォーム送信テスト
// 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) ナビゲーションとルーティングのテスト
// 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() を通じてピクセルレベルの比較を可能にします。
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) ビジュアルテストの設定
// playwright.config.ts 抜粋
export default defineConfig({
use: {
screenshot: 'only-on-failure',
viewport: { width: 1280, height: 720 }
},
expect: {
toHaveScreenshot: {
maxDiffPixels: 100,
animations: 'disabled'
}
}
})
(2) 参照スクリーンショットの生成
# 初回実行時にベースラインスクリーンショットを生成
npx playwright test --update-snapshots
# 以降の実行でベースラインと比較
npx playwright test
▶ サンプル: ビジュアルリグレッションテスト
Output:
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)
// 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:
TypeScript code executed successfully.
7. API モックとリクエスト傍受
Playwright の page.route() はブラウザレベルでネットワークリクエストを傍受し、実際のバックエンドなしでフロントエンド/バックエンドの相互作用をテストできます。
(1) ルート傍受の基本
// グローバル傍受
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:
TypeScript code executed successfully.
// 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:
TypeScript code executed successfully.
8. CI 統合: GitHub Actions
Playwright は GitHub Actions とシームレスに統合され、プッシュのたびに自動的に E2E テストを実行します。
# .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 スイート
// 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()
})
})
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)
❓ よくある質問
webServer 設定は Next.js の npm run dev 自動起動シナリオにも適しています。storageState 機能を使用することをお勧めします: auth.setup.ts でログイン後、Cookie と localStorage を storageState.json に保存し、他のテストの use 設定でそのファイルを参照することで、各テストで繰り返しログインする必要がなくなります。e2e/__screenshots__/)にコミットする必要があります。CI システムは実行時に自動的に比較します。.gitattributes を使用してスクリーンショットファイルに binary のラベルを付けることを忘れないでください。page.route() と MSW のどちらを選ぶべきですか?page.route() はブラウザレベルでリクエストを傍受し、追加の依存関係が不要なため、一時的なモックに適しています。MSW は Service Worker の登録が必要で、コンポーネントテスト(Vitest)に適しています。E2E テストでは、外部依存を減らすためにデフォルトで page.route() を推奨します。workers: 1 を使用してリソース競合を回避; (2) ブラウザバイナリをキャッシュ(~/.cache/ms-playwright); (3) CI では --project=chromium で重要なブラウザのみ実行; (4) 並列化: テストを複数のジョブに分割します。mask オプションを使用してビジュアルテストで動的要素を非表示にします: toHaveScreenshot({ mask: [page.locator('[data-testid="timestamp"]')] })。テキストアサーションには固定値ではなく正規表現パターンを使用します。📖 まとめ
- Playwright の
webServer設定により、Next.js 開発サーバーがテストと共に自動的に起動・停止します - Page Object Model はページ操作を個別のクラスにカプセル化し、テストコードをより簡潔で保守性の高いものにします
- クリティカルパステストは認証、ナビゲーション、フォーム送信などのコアユーザーワークフローをカバーします
toHaveScreenshot()はピクセルレベルのビジュアルリグレッション比較を可能にし、意図しない UI スタイルの変更を検出しますpage.route()はブラウザレベルで API リクエストを傍受し、分離テストとエラーシナリオのシミュレーションに適しています- Playwright を GitHub Actions に統合するには、ブラウザのキャッシュ、並列マトリックスの設定、失敗レポートのアップロードが必要です
📝 練習問題
-
基本問題 (⭐):
goto()、login(email, password)、expectLoggedIn()の 3 つのメソッドを含むLoginPagePage Object を作成し、この POM を使用するテストケースを記述してください。 -
応用問題 (⭐⭐): Next.js アプリケーションの完全な「作成-編集-削除」(CRUD) ワークフローに対して 5 つの E2E テストを記述し、フォームバリデーションエラーと API エラーシナリオのための
page.route()モックを含めてください。 -
発展問題 (⭐⭐⭐): 完全なビジュアルリグレッションテストスイートを実装してください: (1) 5 つのキーページのベースラインスクリーンショットを生成; (2)
expect.toHaveScreenshotとmaxDiffPixelsしきい値を設定; (3) プルリクエストでスクリーンショットを自動比較し、差分レポートをコメントとして追加する CI スクリプトを作成します。