E2E テスト — Playwright
Playwright で実ブラウザのフルシナリオを自動化。セットアップ、locator のルール、認証 storageState、ページオブジェクトパターン、flaky への対処、CI 統合と視覚回帰まで。
29章でコンポーネントとフック単位の自動テストを扱いました。コンポーネントが各々の位置で意図どおりに動くかをしっかり検証してくれます。ただしコンポーネントが組み合わさって作り出す ユーザーシナリオ全体 — 会員登録 → ログイン → Todo 追加 → 完了トグル — が正常に動作するかどうかは、ユニットテストだけでは確信できません。本章ではその空白を埋める道具 Playwright を見ていきます。
29章のユニットテストが小さな部分を頻繁に検証するなら、本章の E2E テストは大きな流れをときどき検証します。2つのレベルは補完関係にあり、どちらか一方で他方を代替しようとするとコストが急激に上がります。そして5部の最終章である33章(デプロイと観測性)では、本章の E2E を preview deploy 環境で自動的に回すパターンへとつなげていきます。
E2E が捕まえるもの #
29章のユニット / 統合テストでは捕まらないバグは、通常次のような形をしています。
- コンポーネント間の contract のずれ: A コンポーネントは string を送るのに B は number を期待。
- ルーティング / state の保存: ページを離れて戻ると入力が消える系のバグ。
- 認証状態と権限: ログインしていない状態で保護されたページに到達してしまう。
- production ビルド固有の問題: 動的 import のタイミング、hydration mismatch。
- 外部依存との統合: 実 DB / Server Action / 認証 flow。
これらのバグは すべての部品が一緒に動く環境 でのみ顔を出します。Playwright は実ブラウザを立ち上げ、ユーザーがクリック・入力する流れをそのまま自動化します。
Playwright セットアップ #
pnpm create playwright@latest質問に答えればよいです(TypeScript Yes、GitHub Actions Yes 推奨)。
インストールが終わると次のファイルが作られます。
modern-react-demo/
├── playwright.config.ts ← 設定
├── tests/ ← E2E テストファイル
│ └── example.spec.ts
└── tests-examples/ ← 学習用例(不要なら削除)playwright.config.ts の中核設定です。
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});要点は次のとおりです。
baseURLでテストごとに毎回ホストを書かなくて済みます。webServerがテスト開始前に Next.js の dev サーバーを自動的に立ち上げます。CI でも同じです。projectsで Chromium / Firefox / WebKit の3ブラウザをマトリックスとして回します。retries: 2(CI 限定)で一時的な flaky を最大2回まで自動で再試行します。
最初のシナリオ — ホームページが描画される #
import { test, expect } from '@playwright/test';
test('ホームページが表示される', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'ホーム' })).toBeVisible();
await expect(page.getByRole('link', { name: '紹介' })).toBeVisible();
});
test('紹介ページに移動する', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: '紹介' }).click();
await expect(page).toHaveURL('/about');
await expect(page.getByRole('heading', { name: '紹介' })).toBeVisible();
});テスト実行。
pnpm exec playwright test--ui フラグを付けると UI モードで開き、各ステップを視覚的に確認できます。
pnpm exec playwright test --uiLocator の優先順位 #
29章の Testing Library で見た原則がそのまま適用されます。ユーザーが画面で認識できるセレクタを優先 します。
推奨順序です。
getByRole(role, { name })— ARIA role + アクセス可能名。最も安定。getByLabel(text)— ラベルで繋がったフォームコントロール。getByPlaceholder(text)— placeholder。getByText(text)— テキスト内容。getByTestId(id)—data-testid属性。最後の手段。
CSS セレクタ(page.locator('.btn-primary'))も可能ですが、CSS クラスはデザインリファクタリングに弱い です。同じセレクタがデザインシステム交換後に壊れるコストが大きいです。role / label ベースが安定的です。
getByTestId は上のオプションがすべて難しいときだけ。data-testid 属性は production ビルドにも残るので、本当に他のセレクタで表現が難しい場合だけに置きます。
認証フローの自動化 — storageState #
Todo アプリのようなほとんどのシナリオはログイン済み状態で動作します。テストごとに毎回ログインフォームを埋めると遅く、不安定です。
Playwright は ログイン状態を一度だけ保存し、すべてのテストが再利用する パターンを標準で提供します。
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('test@example.com');
await page.getByLabel('パスワード').fill('test-password');
await page.getByRole('button', { name: 'ログイン' }).click();
await expect(page).toHaveURL('/dashboard');
await page.context().storageState({ path: authFile });
});playwright.config.ts に setup プロジェクトと依存関係を設定します。
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
});これですべての chromium テストは開始時にすでにログイン済みの状態になります。ログインフォームを毎回通過する必要がなくなり、速度が大幅に上がります。
権限別 storageState #
管理者 / 一般ユーザーのように権限が違うシナリオを扱うなら、storageState ファイルを権限別に作ります。32章(認証とセッション)で作る role-based パターンと自然に噛み合います。
ページオブジェクトパターン #
テストが増えると、同じセレクタや操作が複数のファイルで繰り返されます。
test('Todo を追加', async ({ page }) => {
await page.goto('/todos');
await page.getByLabel('タスク').fill('運動');
await page.getByRole('button', { name: '追加' }).click();
// ...
});
test('Todo を完了', async ({ page }) => {
await page.goto('/todos');
await page.getByLabel('タスク').fill('読書');
await page.getByRole('button', { name: '追加' }).click();
// ... セレクタの重複
});ページオブジェクトパターン は、同じページに対するセレクタとアクションを1つのオブジェクトにまとめます。
import { type Page, type Locator } from '@playwright/test';
export class TodosPage {
readonly input: Locator;
readonly addButton: Locator;
constructor(readonly page: Page) {
this.input = page.getByLabel('タスク');
this.addButton = page.getByRole('button', { name: '追加' });
}
async goto() {
await this.page.goto('/todos');
}
async addTodo(text: string) {
await this.input.fill(text);
await this.addButton.click();
}
async toggleByText(text: string) {
await this.page.getByRole('checkbox', { name: text }).check();
}
}import { test, expect } from '@playwright/test';
import { TodosPage } from './pages/TodosPage';
test('Todo の追加と完了', async ({ page }) => {
const todos = new TodosPage(page);
await todos.goto();
await todos.addTodo('運動');
await expect(page.getByText('運動')).toBeVisible();
await todos.toggleByText('運動');
await expect(page.getByRole('checkbox', { name: '運動' })).toBeChecked();
});注意: ページオブジェクトを早すぎる段階で作らないでください。シナリオが2〜3個集まり、同じセレクタが繰り返され始めたら抽出します。最初の1〜2シナリオは普通のテストのまま残しておくほうが読みやすいです。
遅い E2E を速くする #
E2E は本質的に遅いです。次の道具で速くできます。
1. 並列実行 #
fullyParallel: true が有効なら、1つのファイル内のテスト同士も並列で回ります。workers オプションで同時実行数を調整します。
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 4 : undefined,
});並列化の前提は テスト同士が隔離されていること です。同じユーザー ID を使う2つのテストが同時に回ると衝突します。ユーザー別 / テスト別にデータを隔離するパターンが必要です。
2. 決定的なセレクタ #
getByText('完了') のようなありふれた単語を使うと、画面に複数あるときに曖昧になります。次のパターンで絞り込みます。
await page.getByRole('listitem')
.filter({ hasText: '運動' })
.getByRole('button', { name: '削除' })
.click();3. auto-wait を活かす #
Playwright はアクションの前に要素が見えるまで自動で待ちます。別途 waitFor を使う場面はほとんどありません。次はアンチパターンです。
await page.waitForTimeout(2000); // ほぼ常に間違ったシグナル
await page.click('button');waitForTimeout は単に「よく分からないから待つ」です。短すぎれば flaky、長すぎれば遅い。代わりに 何を待っているかを明示 します。
await expect(page.getByText('登録完了!')).toBeVisible();
await page.click('button');4. retry が必要なケース #
1〜2回ずつ失敗するテストは flaky です。retry を入れておけば一時的な flaky を自動で吸収します。ただし retry は「バグを隠す道具」にもなりえます。flaky なテストは別にマークしておき、原因を追跡する 流れが健全です。
test('たまに壊れるシナリオ', async ({ page }) => {
test.fixme(); // 既知の flaky、対応中
// ...
});視覚回帰(オプション) #
UI のピクセル単位の変化を検証したい場合は toHaveScreenshot を使います。
test('ホームページの視覚的一貫性', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('home.png');
});初回実行で基準スクリーンショットが保存され、以降の実行ではピクセル比較を行います。
コスト: フォント / アンチエイリアシングが環境ごとに違い、false positive がよく出ます。視覚回帰はすべてのページに置かず、デザインシステムの中核コンポーネント または 必ず一貫性を保ちたいページ に限定して置きます。
CI 統合 — GitHub Actions #
.github/workflows/e2e.yml:
name: e2e
on:
push:
branches: [main]
pull_request:
jobs:
e2e:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps
- run: pnpm exec playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7要点は次のとおりです。
playwright install --with-depsでブラウザとシステム依存を一緒にインストールします。if: failure()条件の artifact アップロードで 失敗時に trace / video / screenshot が GitHub Actions に自動保存 されます。デバッグがとても楽になります。
Preview deploy 環境での E2E #
33章(デプロイと観測性)で再び扱いますが、最も強力なパターンは Vercel / Cloudflare Pages の preview deploy URL に対して Playwright を回すこと です。実運用ビルドと運用環境変数で回るので、運用専用のバグをよく捕まえます。
export default defineConfig({
use: {
baseURL: process.env.PREVIEW_URL ?? 'http://localhost:3000',
},
// CI では webServer を立ち上げない
webServer: process.env.PREVIEW_URL
? undefined
: { command: 'pnpm dev', url: 'http://localhost:3000' },
});ユニット / 統合 / E2E の分担 — もう一度 #
29章で描いた表を本章の基準で埋めてみます。
| レベル | 代表的な道具 | 検証対象 | 1個あたりの速度 | 推奨比率 |
|---|---|---|---|---|
| ユニット | Vitest | 純粋関数、小さなコンポーネント、フック | ~10ms | 多め |
| 統合 | Vitest + jsdom | コンポーネント間協調、フォームの流れ | ~100ms | 中くらい |
| E2E | Playwright | ユーザーシナリオ(会員登録 → …) | ~5秒 | 少なめ |
E2E を少なくと言うのは、価値を低く見積もるという意味ではありません。1つの E2E が防いでくれるバグの範囲が広い ので、本数よりもシナリオの選定が重要です。
良い E2E シナリオの基準です。
- ビジネス中核フロー(登録 → 決済 → 利用 のような critical path)
- 回帰が頻発したエリア
- ユニットでは表現しづらい統合的な動作(例: 認証 + 権限 + ルーティング)
自分でやってみよう — 27章方名録の E2E #
29章でユニットテストした方名録を E2E で検証します。
- メッセージ登録シナリオ:
/guestbookに接続して名前とメッセージを入力して登録 → 一覧に新メッセージが見えるかを検証。 - 検証失敗シナリオ: 空の名前で送信 → エラーメッセージが画面に出て、一覧には追加されないかを検証。
- 削除シナリオ: 登録後に削除ボタンをクリック → そのメッセージが消えるかを検証。
- ページオブジェクト抽出: 上の3シナリオを書いたあと、共通セレクタを
GuestbookPageオブジェクトに抽出します。抽出前と後のコードを比較し、読みやすさがどう変わるか自分で見てみてください。
この4ステップを経ると、E2E の書き方と、ページオブジェクトを抽出するタイミングが手に馴染みます。
練習問題 #
- Vitest と Playwright の役割分担. 次の5つの動作について、どの道具で検証するのが適切かを答えてみてください。(a)
formatDateユーティリティ関数の timezone 処理、(b)useToggleフックの boolean トグル、(c) ログイン → 保護されたページへのアクセス遮断、(d) フォーム送信時の空入力検証、(e) 決済フルフロー。答えを書いてから本文の推奨比率の表と照らし合わせます。 - flaky 分析. 次のテストがときどき壊れる理由を推測して直してみてください。
await page.click('button'); await page.waitForTimeout(1000); expect(...)。本文の「auto-wait を活かす」節を参考にします。 - 視覚回帰の適用範囲の選定. 自分が作った(または本書で作った)アプリで、視覚回帰テストを適用すると価値のあるページ2つと、適用するとコストだけ増えるページ2つを選んでみてください。選定基準を1文でまとめます。
一行まとめ: Playwright は実ブラウザでユーザーシナリオを自動化する標準道具です。role / label ベースの locator を優先し、
storageStateで認証を一度だけ通過します。ページオブジェクトはシナリオが集まったあとに抽出し、waitForTimeoutの代わりにexpectで何を待つかを明示します。ユニット / 統合 / E2E は本数比率(多め / 中くらい / 少なめ)よりも シナリオの選定 が本質で、失敗時に trace / video が CI artifact として自動保存されるようにしておくと、デバッグコストが大きく下がります。
次の章 #
次の 31章 パフォーマンス・バンドル・Web Vitals では、出来上がったアプリがユーザーにどれだけ速く届くかを測定し改善する道具を扱います。14章(パフォーマンス最適化)が React 内部の再レンダリングのコストだったなら、31章はユーザーが実際に体感する LCP / INP / CLS の3指標です。そして26章(Suspense と use())の streaming がこれらの指標とどう噛み合うかも、もう一度見ていきます。