Playwrightパターン
CI と本番環境検証のための Playwright E2E テストパターン。
CI安全テスト vs @interactiveテストの分割
すべてのE2EテストがCIで実行できるわけではありません。キーボードショートカット、クリップボードアクセス、デスクトップ固有のインタラクションを必要とするテストにはタグを付けて分割します:
// e2e/basic-navigation.spec.ts -- runs in CI
import { test, expect } from "@playwright/test";
test("loads the home page", async ({ page }) => {
await page.goto("/");
await expect(page.locator("h1")).toBeVisible();
});// e2e/keyboard-shortcuts.spec.ts -- only runs locally
import { test, expect } from "@playwright/test";
test("@interactive Ctrl+S saves document", async ({ page }) => {
await page.goto("/editor");
await page.keyboard.press("Control+KeyS");
await expect(page.locator(".save-indicator")).toHaveText("Saved");
});// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
projects: [
{
name: "ci",
testMatch: /.*\.spec\.ts/,
testIgnore: /.*@interactive.*/,
},
{
name: "interactive",
testMatch: /.*@interactive.*\.spec\.ts/,
},
],
});Tip
CIでは npx playwright test --project=ci を実行し、フルキーボード/クリップボードテストが必要な場合はローカルで npx playwright test --project=interactive を実行します。
いずれのプロジェクトにも一致しないスペックを防ぐ
上記のCI安全 / interactive分割では ci プロジェクトにキャッチオールの testMatch: /.*\.spec\.ts/ を使っているため、すべてのスペックが少なくとも1つのプロジェクトに収集されます。このキャッチオールこそが、ここで説明する罠を防いでいるものです。この罠が現れるのは、各プロジェクトがファイル名のプレフィックスで互いに重ならない形に分割されたセットアップ(例:フィクスチャやアプリごとに1プロジェクト)に移行したときです:
// playwright.config.ts — partitioned by filename prefix (NOT a catch-all)
import { defineConfig } from "@playwright/test";
export default defineConfig({
projects: [
{ name: "fixtureA", testMatch: /fixtureA[^/]*\.spec\.ts/ },
{ name: "fixtureB", testMatch: /fixtureB[^/]*\.spec\.ts/ },
{ name: "fixtureC", testMatch: /fixtureC[^/]*\.spec\.ts/ },
],
});この設定では、fixtureA・fixtureB・fixtureC 以外で始まるファイル名のスペックはどのプロジェクトにもマッチせず、ゼロのプロジェクトに収集されます。Playwrightはエラーなく実行されます――単に実行するものがないだけです――そしてテストの失敗もこれを明かしません。存在しない実行は失敗する結果を生み出さないからです。
修正策は、シングルソース・オブ・トゥルースのメタテストパターンと同様です。ローカルのプッシュ前ゲートとCIの両方に組み込む小さなスクリプトで、すべてのe2eスペックのファイル名が既知のプロジェクトプレフィックスで始まることをアサートします。スペックの列挙は find で再帰的に行ってください(ls e2e/ はトップレベルしか展開せず、サブディレクトリにネストしたスペックを見逃します -- まさにこのガードが塞ぐべき穴です):
# Every e2e spec must start with a known project prefix, or it runs nowhere.
# find recurses into subdirectories; `ls e2e/*.spec.ts` would miss e2e/<dir>/*.spec.ts.
known='fixtureA|fixtureB|fixtureC'
bad=$(find e2e -type f -name '*.spec.ts' | grep -Ev "/($known)[^/]*\.spec\.ts$" || true)
[ -z "$bad" ] || { echo "specs match no Playwright project:"; echo "$bad"; exit 1; }Warning
ガードスクリプトこそが、ファイル名プレフィックスによるプロジェクト分割を安全にするものです。設定ではありません。Playwrightのグリーンなランは、収集されたスペックがパスしたことを証明するだけで、すべてのスペックが実行されたことを証明できません。あるスペックがどのプロジェクトの testMatch パターンにも該当しない場合、Playwrightは無言でそれを除外します。設定が静かにスキップしたスペックを捕まえるのは、このガードだけです。
フレイキーテストの隔離:リトライ回数の非対称性という罠
CI安全テスト vs @interactive の分割に加えて、知っておく価値のある3つ目のタグがあります:@flaky です。このタグが存在する理由は、ある微妙な罠にあります。CIとローカルのプッシュ前ゲートは、しばしば異なるリトライ回数(リトライバジェット)で実行されるため、同じテストが一方ではグリーン、もう一方ではレッドになりうるのです。
罠はここから始まります:
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
// CI retries twice; local runs get zero retries.
retries: process.env.CI ? 2 : 0,
});CIで retries: 2 が設定されていると、2回目や3回目の試行でパスするテストはグリーンとして報告されます。まったく同じテストを retries: 0 のローカル b4push ゲートで実行すると、最初の失敗でレッドになります。テスト自体は変わっていません。変わったのはリトライ回数だけです。ここで腑に落とすべき洞察はこれです:「フレイキーかどうか」はゲート相対的である。 テストは、それがクリアしなければならない最も厳しいゲートの分だけフレイキーなのです。
すでに main に存在する既知のフレイキーテストがある場合、削除してしまうとカバレッジが失われます。そうではなく、タイトルに @flaky タグを付けて、削除することなく厳格なローカルゲートから隔離します:
# scripts/run-b4push.sh -- exclude @flaky from the strict local gate
CHROMIUM_INVERT="@interactive|@flaky"
WEBKIT_INVERT="@flaky"
# Chromium step: skip both @interactive and @flaky
pnpm test:e2e --project=chromium --grep-invert="$CHROMIUM_INVERT"
# WebKit @interactive step: run @interactive but still drop @flaky
pnpm test:e2e --project=webkit --grep="@interactive" --grep-invert="$WEBKIT_INVERT"Chromiumステップは(@interactive に加えて)--grep-invert に @flaky を追加し、WebKitの @interactive ステップも @flaky を除外します。テストはスイートに残ったまま(CIは引き続き実行し、ときおりのリトライを許容します)ですが、リトライ回数ゼロのローカルゲートを引っかけることはなくなります。
Warning
@flaky は隔離であって、恒久的なスキップではありません。すでに main 上で既知のフレイキーと分かっているテストにのみタグを付けてください。新規テストにタグを付けてゲートを通そうとしてはいけません。根本的なレース条件を修正したら、同じPR内でタグを削除してください。そうしないとリストが知らぬ間に膨らみ、本物のカバレッジが失われていきます。
Tip
フレイキーなマシンがプッシュをブロックしないよう、ローカルゲートには脱出口を用意しておきましょう。例:WebKitパスだけをスキップする SKIP_E2E_WEBKIT=1、E2Eステージ全体をスキップする SKIP_E2E=1、そして修正の検証時に隔離されたテストを実行するためのオプトイン RUN_FLAKY=1。
前提条件としての test.skip — pass-by-skip という罠
test.skip は本物の環境依存に使うものです。特定のOSでしか意味をなさないテスト、あるいは特定のサービスが到達可能なときだけ実行すべきテストなどが該当します。それであっても、自分たちのゴールドスタンダードなCIホストでそのspecが実際に実行されているかを監査してください。自分たちが所有するすべての環境でスキップが発火するなら、それは恒久的な pass-by-skip です。テストが実行されなかったからグリーンなのであって、動作が正しいからグリーンなのではありません。そのテストは broken, not flaky(フレイキーではなく壊れている)です。
常に成立すべき前提条件は、ハードアサーションに書くべきです:
// Anti-pattern: silently skips when user is null, hiding a broken setup
test.skip(!user, "no user");
// Correct: fails loudly if setup is broken
expect(user).toBeTruthy();Step 0ゲートについてはデシジョンガイド — 重いテストをいつ書くかを参照してください。前提条件がそもそもテストに属するかどうかの判断基準が記載されています。
E2Eでのエディター入力
コードエディター(CodeMirror、Monaco、ProseMirror、あるいは任意の contenteditable)をPlaywrightから操作するのは、page.fill() よりも厄介です。エディターにvimモードがある場合、page.keyboard.type("hello") は悲惨なことになります。先頭の h でカーソルが左に移動し、i でインサートモードに入り、残りはテキストではなくコマンドとして解釈されてしまうのです。
確実な方法は、DOM Selection API で既存コンテンツをすべて選択し、page.keyboard.insertText() で新しいコンテンツを流し込むことです。insertText はエディターが直接処理する合成 input イベントを発火させ、vimモードのコマンド解釈を完全にバイパスします:
// e2e/helpers.ts
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import os from "os";
// Platform-aware modifier: Meta on macOS, Control on Linux/Windows
export const mod = os.platform() === "darwin" ? "Meta" : "Control";
export async function setEditorContent(page: Page, content: string) {
const editor = page.locator(".cm-content");
await editor.waitFor({ timeout: 5000 });
await editor.click();
// Select all content via the DOM Selection API (works regardless of vim mode)
await page.evaluate(() => {
const el = document.querySelector(".cm-content");
if (!el) return;
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
});
// insertText dispatches an input event the editor handles directly,
// bypassing vim-mode command interpretation entirely.
await page.keyboard.insertText(content);
// Wait for the Lezer parse + decoration updates to land before asserting.
const firstLine = content.split("\n").find((l) => l.trim()) || content;
await expect(page.locator(".cm-content")).toContainText(firstLine.slice(0, 20), {
timeout: 5000,
});
// wait-ok: 500ms is the known auto-save debounce constant; split-pane reads
// content back from the backend, so the test must wait >= the debounce or it races the persist.
await page.waitForTimeout(500);
}プラットフォームを判別する mod ヘルパーにより、同一のspecがmacOS(Meta)とLinux/Windows(Control)の両方でエディターのショートカットを操作できます。テストごとに分岐を書く必要はありません。
Warning
この waitForTimeout(500) は、通常の「任意の waitForTimeout を決して使うな」というルールの正当な例外です。任意の待機が許容されるのは、それが既知のアプリケーション定数にひも付けられている場合(ここでは500msの自動保存デバウンス)に限り、かつ / マーカーでその理由を文書化しているときだけです。理由のない裸の waitForTimeout(500) は依然としてフレイキーの火種です。実在する定数にひも付けるか、適切な expect 待機に置き換えてください。
もう一つの正当なクラスがあります:ある時間ウィンドウの中で障害が発生しないことをアサートするspecです。たとえば、Reactの「Maximum update depth exceeded」スタートアップループを防ぐガード — アプリをマウントして、N msの間エラーが発火しないことをアサートします。このsleepを条件待機に変換するとアサーションが骨抜きになります。ポーリングする対象となる正の事象がないため、ポーリングは即座に解決し、ウィンドウの監視をやめてしまうのです。sleepは残す、定数に名前を付ける、理由を注釈する、絶対に変換しない:
const POST_ MOUNT_ LOOP_ SETTLE_ MS = 2000; test("no update- depth errors on startup", async ({ page }) = > { const errors: string[] = []; page. on("console", (msg) = > { if (msg. type() = = = "error") errors. push(msg. text()); }); await page. goto("/ "); / / wait- ok: asserting ABSENCE of errors over a time window — no positive / / event to poll for; converting to a condition wait would gut the assertion. await page. waitForTimeout(POST_ MOUNT_ LOOP_ SETTLE_ MS); expect(errors. filter((e) = > e. includes("Maximum update depth"))). toEqual([]); });Wait負債をラチェットで削減する
/ アノテーションのない waitForTimeout はすべて負債項目です。正しいかもしれないけれど、一目では判断できない。ラチェットベースラインは、その状況を見えない蓄積から、追跡・減少可能なカウントへと変えます。
チェックスクリプト
スクリプトは、アノテーションのない waitForTimeout 呼び出し(直前2行以内に / コメントがないもの)をgrepし、コミット済みベースラインとファイル単位のカウントを比較します:
#!/usr/bin/env bash
# scripts/check-wait-debt.sh
set -euo pipefail
BASELINE_FILE="e2e/wait-debt-baseline.txt"
SPEC_DIR="e2e"
# Nothing to check until the baseline has been introduced (existence guard).
[ -f "$BASELINE_FILE" ] || exit 0
# Count waitForTimeout calls that lack a // wait-ok: comment in the 2 lines above.
count_unannotated() {
local path="$1" hits
[ -f "$path" ] || { echo 0; return; }
hits=$(grep -n "waitForTimeout" "$path" 2>/dev/null || true)
[ -n "$hits" ] || { echo 0; return; }
printf '%s\n' "$hits" | while IFS=":" read -r lineno _rest; do
start=$(( lineno - 2 )); [ "$start" -lt 1 ] && start=1
sed -n "${start},$((lineno - 1))p" "$path" | grep -q "wait-ok:" || echo found
done | wc -l | tr -d ' '
}
# Expected count for a path: its baseline line, or 0 if absent (the implicit-zero rule).
expected_for() {
awk -v p="$1" '$2 == p { print $1; found=1 } END { if (!found) print 0 }' "$BASELINE_FILE"
}
# Check EVERY spec file (so a file absent from the baseline is held to an implicit 0),
# unioned with the baseline's own paths (to catch a now-deleted file that still has an entry).
failed=0
checked=""
for path in $(find "$SPEC_DIR" -type f -name '*.spec.ts' 2>/dev/null) $(awk '{ print $2 }' "$BASELINE_FILE"); do
case " $checked " in *" $path "*) continue ;; esac
checked="$checked $path"
expected=$(expected_for "$path")
actual=$(count_unannotated "$path")
if [ "$actual" -gt "$expected" ]; then
echo "FAIL $path: $actual unannotated waits (baseline $expected) — annotate new waits with // wait-ok: <why>"
failed=1
elif [ "$actual" -lt "$expected" ]; then
echo "FAIL $path: baseline is stale ($expected → $actual) — shrink the baseline to $actual"
failed=1
fi
done
exit "$failed"ベースラインの形式
ベースラインファイルは、アノテーションのないwaitのファイル単位のカウントを記録します — 行番号ではないので、無関係な編集でチャーンしません:
2 e2e/ editor. spec. ts
1 e2e/ startup. spec. tsルール:
actual > baseline— 新しい裸のwaitが追加された。CIが失敗する。actual < baseline— ベースラインが陳腐化している。CIは「baseline を N に縮小してください」として失敗する。ベースラインは減少のみ許可で、アノテーションなしに増加はできない。ファイルがベースラインに存在しない — 暗黙のカウント0。アノテーションのないwaitがあれば即座に失敗する。
プッシュ前チェックとCIへの組み込み
# .github/workflows/e2e.yml (excerpt)
- name: Check wait debt
run: bash scripts/check-wait-debt.sh# scripts/run-b4push.sh (excerpt)
bash scripts/check-wait-debt.sh存在ガード([ -f "$BASELINE_FILE" ] || exit 0)により、ベースラインファイルが存在する前にスクリプトを導入しても問題ありません — ロールアウト中に壊れません。
既知のトレードオフ
1つ追加・1つ削除のケースは見えません。単一ファイル内でアノテーションのないwaitが1つ増えて1つ減った場合、カウントは変わらず、ラチェットは検知できません。これは負債ラチェットとして許容範囲です — 目標は単調に減少する総数であり、行単位の強制ではありません。このエッジケースはコードレビューと組み合わせて対処してください。
他の負債クラスへの汎用化
同じパターンは、あらゆるgrepできる負債クラスに適用できます:/ コメントのない any キャスト、issue参照のない TODO コメント、期限なしで無効化されたlintルールなど。負債クラスごとに1つのベースラインファイルを導入し、同じプッシュ前パスにすべてを組み込みます。
コンソールエラーモニタリング
Playwrightのテストフィクスチャを拡張して、コンソールエラー時に自動的にテストを失敗させます:
// e2e/fixtures.ts
import { test as base, expect } from "@playwright/test";
export const test = base.extend<{ consoleErrors: string[] }>({
consoleErrors: async ({ page }, use) => {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});
page.on("pageerror", (error) => {
errors.push(error.message);
});
await use(errors);
// Assert no console errors after each test
expect(errors).toEqual([]);
},
});
export { expect };// e2e/app.spec.ts
import { test, expect } from "./fixtures";
const CONSOLE_SETTLE_MS = 1000;
test("home page has no console errors", async ({ page, consoleErrors }) => {
await page.goto("/");
await expect(page.locator("h1")).toBeVisible();
// wait-ok: this test asserts the ABSENCE of console errors, so it must keep
// observing past first paint — late console/pageerror events (a failed lazy
// chunk, a post-hydration warning) fire after the heading is visible. There is
// no positive event to poll for, so hold a bounded settle window before the
// fixture teardown asserts. This is the documented absence-window exception.
await page.waitForTimeout(CONSOLE_SETTLE_MS);
// consoleErrors assertion happens automatically in fixture teardown
});Tip
waitForLoadState("networkidle") を expect(...).toBeVisible() に置き換えるのは、ビューが準備できたことをアサートするには正しい手です — networkidle は、リクエストを発火しないSPAナビゲーションで使われる典型的なアンチパターンだからです。ただしコンソールエラー監視はある時間枠にわたってエラーの不在をアサートするため、初回描画の後に発火するエラーを捉えられるよう、上記の上限つき wait-ok: の沈静化待機も必要です — 準備完了のアサーションだけではテストが早く終わりすぎ、遅れて発生するエラーをグリーンで通してしまいます。不在ウィンドウの例外を含む全カタログとレシピは フレイク根本原因カタログ & デフレイキングレシピ を参照してください。
許容リスト(allowlist)で無害なエラーをフィルタする
上記の expect(errors).toEqual([]) というアサーションは、まっさらなアプリでは機能します。しかし実際のスイートはすぐに壁にぶつかります。たいていの場合、無害なエラーが必ず存在するのです。フレームワークの開発時警告、サードパーティSDKのノイズ、本来のランタイム外でグレースフルに失敗するアダプターなど。厳格な空配列アサーションは、それらすべてをレッドのテストに変えてしまいます。そして典型的な対処――文句が出なくなるまでチェックを緩める――は、リグレッションを捕捉するという価値そのものを捨て去ることになります。
解決策は、精選された許容リスト(curated allowlist)でフィルタする assertNoConsoleErrors() です。これを健全に保つための規律はこうです:許容リストのすべてのエントリは、なぜそのメッセージを無視してよいのかを正当化するwhyコメントを伴うこと。
// e2e/helpers.ts
import { expect } from "@playwright/test";
export function assertNoConsoleErrors(errors: string[]) {
const unexpected = errors.filter((msg) => {
// React DevTools install nag — dev-only, not an app error.
if (msg.includes("Download the React DevTools")) return false;
// Favicon 404 — the mock server has no favicon; harmless.
if (msg.includes("Failed to load resource") && msg.includes("favicon")) return false;
// Tauri listen() fails in browser/mock mode: @tauri-apps/api's transformCallback
// is undefined outside the WebView runtime. The error is caught internally and
// the mock adapter registers its own in-memory listeners instead.
if (msg.includes("Failed to register Tauri event listener")) return false;
// React warns on an iframe rendered with src="" — known v1 limitation of the
// preview pane when no URL is seeded; the iframe renders harmlessly.
if (msg.includes('An empty string ("") was passed to the %s attribute') && msg.includes("src")) {
return false;
}
return true;
});
expect(
unexpected,
`Unexpected console errors:\n${unexpected.join("\n")}`,
).toHaveLength(0);
}Warning
各エントリのwhyコメントは官僚的な儀式ではなく、本質的に重要な部分です。理由がなければ、許容リストは知らぬ間に「すべてを無視する」リストへと腐っていきます。数か月後、誰もそのエントリが本物の既知問題を守っているのか、それとも本物のリグレッションを黙らせるために追加されたのかを覚えておらず、結果として「何も削除しない」が安全策になってしまうのです。1行の理由があれば、次に読む人は根本原因が修正されたその日にエントリを削除できます。許容リストが本来あるべき姿――膨らむのではなく縮む――になるのは、まさにそのときなのです。
CI画像インターセプトによる高速化
CIでは、大きな画像のネットワークリクエストがテストを遅くします。インターセプトして小さなプレースホルダーに置き換えます:
// e2e/fixtures.ts
export const test = base.extend({
page: async ({ page }, use) => {
// Intercept image requests in CI
if (process.env.CI) {
await page.route("**/*.{png,jpg,jpeg,webp,gif}", (route) => {
route.fulfill({
status: 200,
contentType: "image/png",
// 1x1 transparent PNG
body: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
),
});
});
}
await use(page);
},
});Note
zmodのこのパターンにより、画像アセットのネットワークレイテンシーが排除され、CI E2Eテスト時間が40%削減されました。
本番ビルド検証
devサーバーではなく、本番ビルドに対してテストを実行します。これによりビルド固有の問題をキャッチできます:
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
webServer: {
command: "npm run build && npm run preview",
port: 4173,
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: "http://localhost:4173",
},
});// e2e/production.spec.ts
import { test, expect } from "@playwright/test";
test("production build serves all pages", async ({ page }) => {
const urls = ["/", "/docs", "/about", "/contact"];
for (const url of urls) {
const response = await page.goto(url);
expect(response?.status()).toBe(200);
}
});
test("production build has no broken links", async ({ page }) => {
await page.goto("/");
const links = await page.locator("a[href^='/']").all();
for (const link of links) {
const href = await link.getAttribute("href");
if (href) {
const response = await page.goto(href);
expect(response?.status()).toBe(200);
}
}
});Note
webServer がN個のエントリのリスト(フィクスチャやアプリごとに1つ)の場合、インナーループの実行ごとにすべてのNサーバーをビルドして起動する必要があり、数秒が数分になってしまいます。マルチフィクスチャのケースについては、実行ティア の「マルチフィクスチャE2EでT0を現実的なものにする」ガイダンスを参照してください。
シャードCIラン
大規模なテストスイートの場合、複数のCIランナーにシャードします:
# .github/workflows/e2e.yml
jobs:
e2e:
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v4
- run: npx playwright install --with-deps
- run: npx playwright test --shard=${{ matrix.shard }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report-${{ strategy.job-index }}
path: playwright-report/フロントエント専用E2Eのためのモックバックエンドアダプター
実際のバックエンドから独立してフロントエンドの動作をテストする場合:
// e2e/mocks/backend-adapter.ts
import { Page } from "@playwright/test";
export async function mockBackend(page: Page) {
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url());
const mocks: Record<string, unknown> = {
"/api/user": { id: 1, name: "Test User", email: "test@example.com" },
"/api/settings": { theme: "dark", language: "en" },
"/api/documents": [
{ id: 1, title: "Doc 1" },
{ id: 2, title: "Doc 2" },
],
};
const mockData = mocks[url.pathname];
if (mockData) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mockData),
});
} else {
await route.continue();
}
});
}// e2e/frontend.spec.ts
import { test, expect } from "@playwright/test";
import { mockBackend } from "./mocks/backend-adapter";
test.beforeEach(async ({ page }) => {
await mockBackend(page);
});
test("displays user name from mock API", async ({ page }) => {
await page.goto("/dashboard");
await expect(page.locator(".user-name")).toHaveText("Test User");
});Warning
モックバックエンドはフロントエント中心のテストには最適ですが、実際のAPIに対するインテグレーションテストの代わりにはなりません。両方を使用してください:UIの動作にはモック、データフローには実際のAPI。
関連項目
Playwright の CDN がブロックされたサンドボックス化されたコンテナ(Web 版 Claude Code、ロックダウンされた WSL)でこれらのパターンを実行する場合は、制限された環境でのブラウザ検証を参照してください。プリインストール済み Chromium への seeing-eye フォールバック、127.0.0.1 への開発サーバーバインド、PR プレビュー URL を使う検証経路を扱っています。