# Playwright E2E テスト設計ガイド【2026年版】— 壊れない・速い・信頼できるテストを本番品質で

> Playwright で本番品質の E2E テストを設計する完全ガイド。ロールベースのロケーターと Web-first アサーション（自動待機）でフレークを排除し、外部 API をモックし、a11y を CI で検査し、本番ビルドに対して実行する構成、トレース/レポートによる可観測性、CI シャーディングまで、実コードで解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: Next.js, フロントエンド, アクセシビリティ, TypeScript, アーキテクチャ設計, パフォーマンス
- URL: https://tomodahinata.com/blog/playwright-e2e-testing-production-design-guide

## 要点

- E2E は全網羅でなく価値の高い動線に集中する。純粋ロジックは単体、フロー・SEO・a11y は E2E と役割分担する
- ロケーターは getByRole / getByLabel のロール基準で書き、CSS/XPath を避けて壊れにくさと a11y 検証を両取りする
- Web-first アサーション（toBeVisible 等）で自動待機し、waitForTimeout の固定スリープはフレークの元として捨てる
- 外部 API は page.route でモックし、決済・メール送信の本物に到達させない（コスト・信頼性・セキュリティ）
- next dev でなく next start の本番ビルドに対し実行し、トレース・スクショ・CI シャーディングで運用に耐えさせる

---

E2E は「リリースして大丈夫か」を最後に保証する砦です。だからこそ、遅くて壊れやすいテストは無いほうがマシ。設計が品質を決めます。

---

## 1. 何を E2E で・何を単体で検証するか

テストには階層があります。すべてを重い E2E で書くと、遅く・脆く・高コストになります。

| 対象 | 適したテスト | 理由 |
| ---- | ------------ | ---- |
| 純粋ロジック（バリデーション・計算） | 単体（Vitest 等） | 最速・最安・網羅しやすい |
| ユーザー動線（入力→送信→確認） | E2E（Playwright） | 実ブラウザでの統合を保証 |
| SEO 配管（JSON-LD・sitemap・RSS） | E2E | レンダリング後の出力を検証できる |
| アクセシビリティの自動検査 | E2E（axe） | 実 DOM に対して走らせる |

原則は「**価値の高い数本の E2E に集中**」。例えばこのサイトでは、フォーム送信・ナビゲーション・予約導線・a11y を E2E で押さえ、データ整形やレート制限の窓計算は単体テストに寄せています。

---

## 2. ロケーター：ロール基準で書く（壊れにくさ＝a11y）

最も重要な設計判断がロケーター戦略です。`page.locator(".btn-primary")` のような CSS セレクタは、デザイン変更で即壊れます。**ユーザーが認識する手がかり（役割・ラベル・テキスト）**で要素を掴みます。

```ts
// ❌ 実装詳細に依存。クラス名が変わると壊れる
await page.locator("div.form > button.submit").click();

// ✅ ユーザー視点。役割とアクセシブルネームで掴む
await page.getByRole("button", { name: "送信する" }).click();
await page.getByLabel("メールアドレス").fill("taro@example.com");
await page.getByRole("radio", { name: /プロジェクト単位/ }).click();
```

副次効果が強力です。`getByRole` / `getByLabel` で要素を掴めるということは、**その要素にアクセシブルな名前が付いている証拠**です。ロケーター戦略がそのまま a11y のスモークテストになります（[WCAG 2.2 実装ガイド](/blog/react-nextjs-web-accessibility-wcag22-guide)）。

---

## 3. Web-first アサーション：固定スリープを捨てる

Playwright のアサーションは**自動で待機・再試行**します。「表示されるまで」「有効になるまで」を Playwright が面倒見るので、`waitForTimeout` は基本不要です。固定スリープはフレークの最大要因です。

```ts
// ❌ フレークの温床：環境が遅いと落ち、速いと無駄に待つ
await page.click("button");
await page.waitForTimeout(3000);
expect(await page.isVisible(".success")).toBe(true);

// ✅ 条件が満たされるまで自動で待つ（タイムアウトまで再試行）
await page.getByRole("button", { name: "送信する" }).click();
await expect(page.getByText("お問い合わせを送信しました")).toBeVisible();
```

> 例外：意図的な時間ゲート（例「送信は表示から2.5秒後まで無効」というスパム対策）を検証する場合に限り、その時間を待つのは正当です。「不確実さを誤魔化すためのスリープ」と「仕様としての待機」は区別します。

---

## 4. 外部 API のモック：本物に到達させない

E2E でメール送信・決済・課金などの外部副作用を**本物に飛ばしてはいけません**（コスト・信頼性・セキュリティの三重の問題）。`page.route` でリクエストを傍受し、ペイロードの形を検証しつつ、任意のレスポンスを返します。

```ts
import { test, expect, type Page, type Route } from "@playwright/test";

// /api/contact を傍受し、Resend 等の本物に到達させない。
// 送信ボディの形を確認しつつ、指定のステータス/JSON を返す。
async function mockContactApi(
  page: Page,
  { status = 200, body = { success: true }, onRequest }: {
    status?: number;
    body?: Record<string, unknown>;
    onRequest?: (payload: unknown) => void;
  } = {},
) {
  await page.route("**/api/contact", async (route: Route) => {
    onRequest?.(route.request().postDataJSON());
    await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
  });
}
```

これで「正常系」「429（レート制限）」「500（サーバーエラー）」を**確定的に**再現できます。回復性（失敗時の UI）も、外部を待たずに検証できます。

```ts
test("レート制限時の文言を出す", async ({ page }) => {
  await page.goto("/contact");
  await mockContactApi(page, { status: 429, body: { error: "rate limited" } });
  // ...入力して送信...
  await expect(page.getByText("短時間に複数回送信されています")).toBeVisible();
});
```

送信ボディの形を `onRequest` で捕まえ、`toMatchObject` で検証すると、「正しいペイロードで・1回だけ」到達したことまで保証できます。

---

## 5. アクセシビリティを E2E に組み込む

`@axe-core/playwright` で、実 DOM に対して WCAG 違反を自動検査します。デザイン変更でコントラストやラベルが壊れても CI で気づけます。

```ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("主要ページに重大な a11y 違反がない", async ({ page }) => {
  await page.goto("/");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  expect(results.violations).toEqual([]);
});
```

自動検査は万能ではありません（検出できるのは機械判定可能な3〜5割）。キーボード操作やスクリーンリーダーの体感は手動確認が要ります。それでも「退行を CI で止める」価値は絶大です。

---

## 6. SEO 配管のテスト：レンダリング後の出力を検証する

構造化データ（JSON-LD）・sitemap・RSS・robots は、壊れても画面では気づきにくい。E2E ならレンダリング後の出力を直接検証できます。

```ts
test("ブログ記事に BlogPosting 構造化データが出る", async ({ page }) => {
  await page.goto("/blog/tanstack-query");
  const raw = await page
    .locator('script[type="application/ld+json"]')
    .first()
    .textContent();
  const data = JSON.parse(raw ?? "{}");
  expect(data["@type"]).toBe("BlogPosting");
  expect(data.headline).toBeTruthy();
});
```

集客に直結する SEO 要素を「壊れたら落ちる」状態にしておくと、リリースのたびに安心できます。

---

## 7. 本番ビルドに対して実行する構成

`next dev` には開発専用の挙動（未最適化・追加ログ）があります。E2E は**本番ビルド（`next start`）**に対して走らせ、本番に近い条件で検証します。Playwright の `webServer` がサーバー起動を管理します。

```ts
// playwright.config.ts（要点）
import { defineConfig, devices } from "@playwright/test";
const PORT = Number(process.env.PLAYWRIGHT_PORT ?? 3100);

export default defineConfig({
  testDir: "./tests/e2e",
  expect: { timeout: 7_500 },
  fullyParallel: true,
  forbidOnly: Boolean(process.env.CI),     // CI で .only を禁止
  retries: process.env.CI ? 2 : 0,         // 再試行は CI のみ
  use: {
    baseURL: `http://127.0.0.1:${PORT}`,
    trace: "on-first-retry",               // 失敗を再現する証拠を残す
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
  projects: [
    { name: "chromium-desktop", use: { ...devices["Desktop Chrome"] } },
    { name: "mobile-safari", use: { ...devices["iPhone 14"] } }, // モバイルも検証
  ],
  webServer: {
    command: `npx next start --port ${PORT}`, // 専用ポートで本番ビルドを起動
    url: `http://127.0.0.1:${PORT}`,
    reuseExistingServer: !process.env.CI,     // ローカルは再利用、CI は毎回新規
    timeout: 120_000,
  },
});
```

専用ポート（例 3100）にすると、別端末で `next dev` を動かしたままでも衝突しません。デスクトップとモバイルの両プロジェクトで、レスポンシブの破綻も拾えます。

---

## 8. 可観測性とフレーク対策（信頼性）

E2E が「たまに落ちる」状態は、テスト全体の信頼を失わせます。落ちないテストにする設計が要です。

- **トレース・スクショ・動画を失敗時に残す。** `trace: "on-first-retry"` で、再現困難な失敗も Trace Viewer で時系列に追えます。
- **再試行は CI のみ・少回数。** ローカルで `retries: 0` にすると、フレークを開発中に発見できます。CI は 1〜2 回。
- **テスト間を独立に。** 共有状態に依存しない。各テストは自分でセットアップする（`beforeEach` で `goto`）。
- **並列実行を前提に設計する。** `fullyParallel` でも壊れないよう、テスト固有のデータを使う。
- **固定スリープを使わない**（第3章）。

---

## 9. CI での実行：速さとコストの最適化

- **シャーディングで分割並列。** `--shard=1/4` のようにジョブを分け、ブロブレポートを後でマージすると、総時間を短縮できます。
- **段階的に走らせる。** PR では主要ブラウザ＋重要動線、main へのマージ時に全ブラウザ＋全シナリオ、と段階化するとコスト効率が上がります。
- **ブラウザバイナリをキャッシュ。** `npx playwright install` の取得をキャッシュして CI 時間を削減。
- **HTML レポートを成果物に。** 失敗時の調査を速くします。

「全部を毎回・全ブラウザで」は YAGNI になりがちです。守りたいリスクに対して、実行範囲を設計します。

---

## 10. アンチパターン

- ❌ **`waitForTimeout` で待つ。** フレークの主因。Web-first アサーションで自動待機する。
- ❌ **CSS/XPath セレクタに依存する。** デザイン変更で壊れる。ロール/ラベルで掴む。
- ❌ **本物の外部 API（決済・メール）に到達させる。** コスト・信頼性・セキュリティの問題。`page.route` でモック。
- ❌ **`next dev` に対してテストする。** 本番ビルド（`next start`）で検証する。
- ❌ **テスト間で状態を共有する。** 順序依存はフレークと調査困難を生む。各テストを独立に。
- ❌ **何でも E2E で書く。** 純粋ロジックは単体へ。E2E は価値の高い動線に集中。
- ❌ **実装詳細（内部 state・関数）をテストする。** ユーザーに見える振る舞いを検証する。

---

## 11. FAQ（よくある質問）

**Q. E2E はどれくらいの数を書くべき？**
A. 「壊れたら致命的な動線」に絞るのが基本です。テストトロフィーの考え方では、土台を単体・結合で厚くし、E2E は少数精鋭にします。

**Q. `getByRole` と `getByTestId`、どちらを使う？**
A. まず `getByRole` / `getByLabel`（ユーザー視点＝a11y にも効く）。それで一意に掴めない場合の最後の手段として `data-testid` を使います。

**Q. ログインが必要なフローはどうする？**
A. 認証状態を `storageState` に保存して使い回すのが定石です。毎テストでログイン UI を踏まず、高速かつ安定します。

**Q. フレーク（不安定テスト）が止まらない。**
A. 固定スリープ・順序依存・共有状態・実装詳細依存のいずれかが大半の原因です。Web-first アサーションと独立性で潰し、Trace Viewer で根本原因を特定します。

**Q. Playwright と Cypress の違いは？**
A. Playwright はマルチブラウザ（Chromium/WebKit/Firefox）対応、並列実行、自動待機、トレースが強力です。本番ビルド検証や CI 並列との相性も良好です。

---

## まとめ：E2E は「リリースの信頼」を作る設計物

E2E テストは、書けば安心というものではありません。**壊れにくく・速く・確定的**であって初めて、チームが信頼してリリースできます。

1. **役割分担。** 純粋ロジックは単体、動線・SEO・a11y は E2E。
2. **ロール基準のロケーター**で、壊れにくさと a11y を両取りする。
3. **Web-first アサーション**で固定スリープを排除し、フレークを断つ。
4. **外部 API はモック**し、コスト・信頼性・セキュリティを守る。
5. **本番ビルドに対して実行**し、可観測性（トレース）と CI 並列で運用に耐えさせる。

テストが信頼できると、開発速度が上がります。「変更が怖くない」状態こそ、保守性と拡張性の本質です。

**本番運用に耐える E2E・テスト基盤の構築、あるいは品質保証の仕組みづくりが必要な場合は、お気軽にご相談ください。** 下記の事例では、複数サービスを束ねる社内プラットフォームを、品質と信頼性を重視して設計・実装した過程を紹介しています。
