zudo-test-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

Playwrightパターン

CI と本番環境検証のための Playwright E2E テストパターン -- CI 安全なテスト分割、コンソールエラーモニタリング、画像インターセプト、本番ビルド検証、シャード CI ラン。

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",
      grepInvert: /@interactive/,
    },
    {
      name: "interactive",
      grep: /@interactive/,
    },
  ],
});

Warning

testMatch/testIgnoreファイルパスでフィルタし、grep/grepInvertテストタイトルでフィルタします。上記の @interactive タグはテストタイトル(test("@interactive Ctrl+S saves document", ...))に存在しており、ファイル名 e2e/keyboard-shortcuts.spec.ts には含まれていません——パスベースの testMatch: /.*@interactive.*\.spec\.ts/ はこのファイル名にゼロ件しかマッチしないため、interactive プロジェクトは何も収集できず、タグ付きのテストは除外されるどころか ci 側にすり抜けてしまいます。上記のようにタイトルで grep/grepInvert フィルタするか、Playwright 1.42以降であればファーストクラスのタグ機能(test("Ctrl+S saves document", { tag: "@interactive" }, ...))とコマンドラインの --grep @interactive を使ってください。このページの @flaky 隔離は、すでにタイトルでのフィルタを正しく行っています——それが手本にすべきパターンです。

Tip

CIでは npx playwright test --project=ci を実行し、フルキーボード/クリップボードテストが必要な場合はローカルで npx playwright test --project=interactive を実行します。

いずれのプロジェクトにも一致しないスペックを防ぐ

上記のCI安全 / interactive分割は ci プロジェクトの testMatchデフォルトのまま(パス制限なし)にしているため、タイトルにかかわらずすべてのスペックファイルが収集されます——この暗黙のキャッチオールこそが、ここで説明する罠を防いでいるものです。この罠が現れるのは、各プロジェクトがファイル名のプレフィックスで互いに重ならない形に分割されたセットアップ(例:フィクスチャやアプリごとに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/ },
  ],
});

この設定では、fixtureAfixtureBfixtureC 以外で始まるファイル名のスペックはどのプロジェクトにもマッチせず、ゼロのプロジェクトに収集されます。Playwrightはエラーなく実行されます――単に実行するものがないだけです――そしてテストの失敗もこれを明かしません。存在しない実行は失敗する結果を生み出さないからです。

修正策は、シングルソース・オブ・トゥルースのメタテストパターンと同様です。ローカルのプッシュ前ゲートとCIの両方に組み込む小さなスクリプトで、すべてのe2eスペックのファイル名が既知のプロジェクトプレフィックスで始まることをアサートします。スペックの列挙は find再帰的に行ってください(ls e2e/*.spec.ts はトップレベルしか展開せず、サブディレクトリにネストしたスペックを見逃します -- まさにこのガードが塞ぐべき穴です):

# 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は無言でそれを除外します。設定が静かにスキップしたスペックを捕まえるのは、このガードだけです。

不正な workers 値はゼロ件のテストを実行してgreenで終了する

上のガードは、どのプロジェクトにも収集されないスペックを捕まえます。同じ偽greenへ至るもう1つの経路があり、それはこのガードを無傷ですり抜けます:すべてのスペックが自分のプロジェクトに完璧に一致しているのに、ランはそれでも何も実行しない——workers の値だけが原因で。

config.workers が受け付けるのは数値4)またはパーセンテージ文字列"50%")です。数値の文字列は受け付けません:workers: "3"config.workers must be a number or percentage というエラーになります。この非対称性こそが、誰もが思いつくenvオーバーライドのイディオムを罠にします:

// playwright.config.ts -- Anti-pattern. Number() is the whole bug.
export default defineConfig({
  workers: Number(process.env.PW_WORKERS),
});

Number("abc")NaN です——typoした、あるいは古くなった環境変数。Number("50%")また NaN です——Playwright自身のネイティブなパーセンテージ構文が、それを受け付けるために書かれたはずの型変換によって破壊されるのです。そして workersNaN のとき、Playwrightはエラーになりません。Running N tests の行も、pass/failのサマリーも出力せず、exit 0 で終了します

$ PW_WORKERS=abc npx playwright test
$ echo $?
0

これは、何も実行していないのに成功を報告するゲートです。

この失敗は非対称で、だからこそカジュアルなテストを生き延びます——開発者が試そうと思いつく値は、行儀よく振る舞う値ばかりだからです:

PW_WORKERSNumber() の結果Playwrightの振る舞い
44正常に実行
00大きな音でエラー -- config.workers must be a positive number
-2-2大きな音でエラー -- 同じメッセージ
1.51.5黙って丸める。スイートは実行される
50%NaNゼロ件のテストを実行し、exit 0
abcNaNゼロ件のテストを実行し、exit 0

オーバーライドは設定ロード時に検証し、不正な値は「何も起きない」時点ではなくブート時点で失敗させてください:

// playwright.config.ts
function resolveWorkers(raw: string | undefined): number | string | undefined {
  if (raw === undefined) return undefined; // let Playwright pick its default
  if (/^\d+%$/.test(raw)) return raw;      // pass percentages through as strings
  const n = Number(raw);
  if (!Number.isInteger(n) || n < 1) {
    throw new Error(`PW_WORKERS must be a positive integer or a percentage -- got "${raw}"`);
  }
  return n;
}

export default defineConfig({
  workers: resolveWorkers(process.env.PW_WORKERS),
});

未設定のケースで undefined を返すことが重要です:設定の作者が推測した数値を固定するのではなく、判断をPlaywright自身のデフォルトへ返します。

Warning

Playwright 1.58.2 に対して検証済み——silent-on-NaN はドキュメント化された保証ではなく観測された振る舞いなので、あなたがピン留めしているバージョンに対して再確認してください。このルールはこの1つのキーを超えて一般化します:環境変数から素の Number() を通して供給されるあらゆるランナー設定値は、人間には意味のある文字列を NaN に変換しえます。そして NaN がエラーになるか静かに劣化するかは、各ランナーが勝手に決めることです。すべてのenvオーバーライドを境界でパースして検証してください。型変換の結果を未チェックのまま設定フィールドへ到達させてはいけません。

これはファイル名プレフィックスガードと同じ教訓の、1つ下のレイヤーです:greenなランが証明するのは、実行されたテストがパスしたことだけ——何かのテストが実行されたことは決して証明しません。 最も安価な独立チェックは、exit codeではなく件数のアサートです。自分のレポーター出力を読んでテスト件数が非ゼロであることを確認するゲートは、このクラスのすべてのメンバーを一度に捕まえます——収集されないスペック、NaN のワーカー数、そして次にどんなメカニズムが現れようとも。(これは収集件数のガードです:すべてのスペックが test.skip するスイートはこれを通過します——それは別のpass-by-skipという罠です。)

# The gate ran green -- but did it collect anything? Fail if the suite collected nothing.
# Note the report must live OUTSIDE outputDir to survive; see the outputDir section below.
REPORT=b4push-reports/report.json
rm -f "$REPORT"  # a stale report from a prior run would satisfy both checks below
PLAYWRIGHT_JSON_OUTPUT_NAME="$REPORT" npx playwright test --reporter=json

[ -f "$REPORT" ] || { echo "FAIL: no report written -- the run did not reach the reporter"; exit 1; }
count="$(jq '[.. | objects | select(.tests? and .title?)] | length' "$REPORT")"
[ "$count" -gt 0 ] || { echo "FAIL: suite collected 0 specs -- the gate is vacuous"; exit 1; }

Note

workers の設定ミスはゲートを定義するファイルの中に住んでいるため、そこへの編集はルール8 — ゲートを不正操作しないの対象になります:ランナー設定は、他のあらゆるゲートファイルと同じくフレッシュコンテキストのレビューを受けます。ルール8はすでに、アサーションに触れずにゲートを無力化する手段として testMatch の絞り込みと --grep-invert フィルターを名指ししています。NaN に解決される workers 値はそのどちらよりも完全にゲートを無力化し、しかもdiffの上ではパフォーマンス調整に見えるのです。

フレイキーテストの隔離:リトライ回数の非対称性という罠

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内でタグを削除してください。そうしないとリストが知らぬ間に膨らみ、本物のカバレッジが失われていきます。

そして、リトライ回数ゼロのローカルゲートが赤くした何かにタグを付ける前に:同じスペックがラン間で失敗しているかを確認してください。ランのたびに異なるスペックへ着地する失敗——古典的には、フルスイートランの終盤に出るcontext teardownのタイムアウト——はレーンのオーバーサブスクリプションであって、フレイキーテストではありません。最新の犠牲者にタグを付けても、健全なカバレッジを停止し、レーンの欠陥に次の犠牲者を選ばせるだけです。

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ゲートについてはデシジョンガイド — 重いテストをいつ書くかを参照してください。前提条件がそもそもテストに属するかどうかの判断基準が記載されています。

セレクターのアクショナビリティ:正しそうに見える要素が誤ったターゲットであるとき

locator の契約は、DOMノードに解決することです。実際に操作できるノードに解決すること_ではありません_ — そしてリッチなUI(canvasエディター、複数サーフェスのビューポート、ポータル化されたオーバーレイ)では、その2つのノードは日常的に食い違います。要素はインスペクター上では正しく見え、toBeAttached() はパスし、それでもクリックは誤ったサーフェスに着地するかタイムアウトします。この罠には3つの形が繰り返し現れ、いずれも1つの修正策を共有しています:セレクターに、意図した「アクティブなサーフェスまたは状態」をエンコードさせ、操作する前にまさにそのターゲットに対してアクショナビリティをアサートすること — 要素の_タイプ_や_最初のマッチ_でセレクトして、解決されたノードが生きているものであることを祈るようなことは、決してしてはいけません。

重複するサーフェスに対する .first() は隠れている方を掴む

症状: ビューポートには表示されている <canvas> がちょうど1つだけあるのに、テストはどの要素も操作できていません — クリックは画面に決して現れないサーフェスに描画されるか、ドラッグが空白に着地します。テスト途中のスクリーンショットを見ると、ポインター操作がレンダリングされたコンテンツを完全に外していることがわかります。

診断: DOMの順序はレンダリングの順序ではありません。フレームワークは常に複数のサーフェスをマウントしたままにします — 前のスライド、オフスクリーンのダブルバッファ、display:none のエクスポート用canvasなど — そして page.locator("canvas").first() は、描画されている方ではなく_ソース_上で最初に現れる方を返します。要素のタイプ(canvas)と位置(first)は、セレクトの拠りどころにできる中で最も弱い2つのシグナルです:どちらも、どのサーフェスがアクティブなのかについて何も語りません。

修正: アクティブなサーフェスをセレクターにエンコードします — 表示されている/アクティブなコンテナにスコープする、状態属性([data-active][aria-hidden="false"])でフィルタする、あるいは canvas:visible をセレクトする — そして操作する前に、選んだターゲットに対して toBeVisible() をアサートします:

// Anti-pattern: DOM order != render order. `.first()` returns the first
// <canvas> in source order — which may be a hidden, offscreen, or torn-down
// surface the framework has left mounted.
await page.locator("canvas").first().click();

// Fix: scope to the active container and select the visible surface, then
// prove you picked the rendered target before acting on it.
const surface = page.locator('[data-active="true"] canvas:visible');
await expect(surface).toBeVisible();
await surface.click();

スコープに使える状態属性がアプリにない場合、.filter({ visible: true }) は複数マッチのlocatorを画面上の1つに絞り込みます — ただし、サーフェスのコンテナに data-active / aria-hidden マーカーを追加するのが恒久的な修正です。描画状態から推測するのではなく、_意図した_サーフェスを明示的にするからです。

スライダーとテキスト入力で共有されるラベルはストリクトモードを引っかける

症状: どんな操作が走るよりも前にテストがスローします — 失敗は、インタラクションではなく_解決_の段階で起きます:

Error: strict mode violation: getByLabel('Amount') resolved to 2 elements:
    1) <input type="range" aria-label="Amount"> …
    2) <input type="text"  aria-label="Amount"> …

診断: 同じパネル配下にあるrange inputと、それと対になった数値/テキスト入力の両方が、アクセシブルネーム Amount を公開しています(値ボックスが連動したスライダーが典型例です)。getByLabel はアクセシブルネームでマッチし、ロールを問いません。そのため両方をセレクトしてしまい、Playwrightのストリクトモードは、あなたがどちらを意図したのかを推測することを拒みます。

修正: この2つのコントロールは_ネーム_を共有していますが_ロール_は共有していません — ロールで曖昧さを解消します。range inputは slider であり、値ボックスは textboxtype="text")または spinbuttontype="number")です。range input は fill できない 点に注意してください — fill()Input of type "range" cannot be filled をスローします — そのため、スライダーはロールに応じた操作で駆動し、値ボックスを fill() します:

// Anti-pattern: name-only, role-agnostic — resolves 2 elements, throws.
await page.getByLabel("Amount").fill("50");

// Fix: same name, different role — the ambiguity disappears.
await page.getByRole("textbox", { name: "Amount" }).fill("50"); // value box
await page.getByRole("slider", { name: "Amount" }).press("ArrowRight"); // nudge the range input

// If both controls genuinely share a role too, scope to the dialog/panel
// and reach for a test id rather than the shared label:
await page
  .getByRole("dialog", { name: "Transfer" })
  .getByTestId("amount-input")
  .fill("50");

ターゲットが表示されているのにオーバーレイがクリックをインターセプトする

症状: ターゲットは表示されており_かつ_有効なのに、.click() がタイムアウトします。コールログには、あなたがセレクトしたのとは別の要素の名前が挙がっています:

locator.click: Timeout 30000ms exceeded.
Call log:
  - waiting for element to be visible, enabled and stable
  - element is visible, enabled and stable
  - scrolling into view if needed
  - <div class="modal-backdrop">…</div> from <div id="portal-root">…</div> subtree intercepts pointer events
  - retrying click action

診断: Playwrightはクリックをディスパッチする前に4つのアクショナビリティチェックを実行します — 要素は 表示されている(visible)安定している(stable、アニメーション途中でない)有効(enabled)、そして ポインターイベントを受け取れる(クリック地点の最前面に他の何も乗っていない) 必要があります。ポータル化されたオーバーレイ — モーダルのバックドロップ、トースト、開いたばかりのドロップダウン、ビューポート全面のローディングベール — が、その座標でターゲットの上に重なっているため、最初の3つはパスしても4つ目のチェックは決してパスしません。ターゲット自体は問題なく、何かがその上に乗っているのです。

修正: インターセプトしているレイヤーが去るのを待つか、本当のインタラクティブなレイヤーにスコープしてから、通常どおりクリックします。そうすればアクショナビリティチェックが引き続きあなたを守ってくれます:

// Fix: wait for the intercepting overlay to go away, then click normally.
await expect(page.locator(".modal-backdrop")).toBeHidden();
await page.getByRole("button", { name: "Save" }).click();

Warning

ここで { force: true } に手を伸ばしては いけませんforce は「ポインターイベントを受け取れる」チェックをスキップし、何があろうとターゲットの座標でクリックをディスパッチします — その結果、オーバーレイがイベントを飲み込むか、あるいは何も飲み込まないかのどちらかです。クリックしたかったコントロールは一度もクリックされていないのにテストはグリーンになり、「何かが上に乗っている」と正しく告げていたアクショナビリティのエラーは黙らされてしまいます。force: true は、インターセプトが偽陽性であると_証明できた_まれなケース(例:Playwrightがなおカウントしてしまう、装飾目的の pointer-events: none レイヤー)のためのものであり、本物のオーバーレイに対する修正では決してありません。クリックを着地させるために force を追加している自分に気づいたときは、ほぼ確実にバグを修正しているのではなく覆い隠しています。

Playwrightがターゲットを非表示またはインターセプトされていると報告し、しかもセレクターが正しそうに_見える_ときは、推測をやめて実際に見てください:スクリーンショットを撮るか、ターゲットとその上に乗っている何かの両方について、算出されたジオメトリ(bounding box、z-indexpointer-events)をダンプします。クリック地点を実際に占めているものが見えれば、「クリックが謎めいてタイムアウトする」は「この座標のトーストに400msの退出アニメーションがある」へと変わります。レベル5: 決定論的 + 視覚的検証の決定論的な算出スタイルダンプとスクリーンショットレビューが、まさにこのためのツールです — ストリクトモードのエラーがほのめかすだけの、最前面にある要素のジオメトリを見せてくれます。

Tip

インタラクションが、単にマウントされていることではなくアプリが_準備完了_であることに依存する場合は、より長いタイムアウトではなく、アプリが制御する明示的な準備完了シグナルを待ってください — await expect(page.locator('[data-ready="true"]')).toBeVisible()、あるいは目に見える準備完了マーカーです。クリックの { timeout: 30000 } を引き上げても、誤ったサーフェス・曖昧なラベル・アニメーション中のオーバーレイが依然として噛みつける時間枠を広げるだけで、症状に対処しているにすぎません。アプリがサーフェスを描画しオーバーレイを片付け終えた時点で一度だけセットする data-ready 属性があれば、上記の3つの罠すべてを、操作する前にアサートできる単一の肯定的な事前条件へと畳み込めます。

通底するのはこれです:要素のタイプやソース上の位置ではなく、意図した_アクティブなサーフェスまたは状態_でセレクトする;操作する前に、実際のターゲットに対して可視性/アクショナビリティをアサートする;共有されたラベルは、ロールかパネルにスコープしたtest idで曖昧さを解消する;そして準備完了に依存するインタラクションは、太らせたタイムアウトではなく明示的なシグナルでゲートする。

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の自動保存デバウンス)に限り、かつ // wait-ok: <why> マーカーでその理由を文書化しているときだけです。理由のない裸の 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負債をラチェットで削減する

// wait-ok: <why> アノテーションのない waitForTimeout はすべて負債項目です。正しいかもしれないけれど、一目では判断できない。ラチェットベースラインは、その状況を見えない蓄積から、追跡・減少可能なカウントへと変えます。

チェックスクリプト

スクリプトは、アノテーションのない waitForTimeout 呼び出し(直前2行以内に // wait-ok: コメントがないもの)を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-ok: <why> コメントのない 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

zzmodのこの画像インターセプションパターンにより、画像アセットのネットワークレイテンシーが排除されます。CI=true のとき @smoke レーンで実行されます。

本番ビルド検証

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("/");
  // Collect hrefs as plain strings before navigating -- .all() locators
  // re-resolve against whatever page is currently loaded, so navigating away
  // mid-loop would have iteration 2+ walk hrefs from a random later page.
  const hrefs = await page.$$eval("a[href^='/']", (els) =>
    els.map((a) => a.getAttribute("href")),
  );
  for (const href of hrefs) {
    if (href) {
      const response = await page.goto(href);
      expect(response?.status()).toBe(200);
    }
  }
});

Note

webServer がN個のエントリのリスト(フィクスチャやアプリごとに1つ)の場合、インナーループの実行ごとにすべてのNサーバーをビルドして起動する必要があり、数秒が数分になってしまいます。マルチフィクスチャのケースについては、実行ティア の「マルチフィクスチャE2EでT0を現実的なものにする」ガイダンスを参照してください。

モノレポ開発サーバーのためのバンドル依存E2Eレーン

上の本番ビルドレーンが検証するのはビルドの正しさです — しかし多くのアプリスイートは、メインのE2Eボリュームを本番ビルドに対して実行できません:MSWのサービスワーカーセットアップ、dev限定ルート、dev限定のテストブリッジ(import.meta.env.DEV ガードの内側で window に公開されるstate)は、いずれも開発サーバーの下にしか存在しないからです。これらのレーンは競合ではなく補完関係にあります:インタラクティブな作業とHMRにはソース配信の開発サーバー、CIのメインE2Eボリュームにはこのバンドル依存レーン、ビルド固有の検証には本番ビルド。この中間レーンの存在を強制するのが、pnpmワークスペースのモノレポに潜む、隠れたテストごとの税金です。

その税金: development エクスポート条件を通して解決されるワークスペースパッケージは —

"exports": {
  ".": {
    "types": "./dist/index.d.ts",
    "development": "./src/index.ts",
    "default": "./dist/index.js"
  }
}

生のソースとして、モジュール1つにつきHTTPリクエスト1本で配信されます。インタラクティブな開発ではまさにそれが望みの動作です(パッケージのソースへのHMR)。しかしPlaywrightのテストはそれぞれ空のHTTPキャッシュを持つ新しいブラウザコンテキストを得るため、すべてのテストがパッケージのモジュールグラフ全体を再ダウンロードします。重いパッケージ(実例ではモジュール約3,500個のパターンジェネレーターレジストリ)では、そのグラフに触れるフローを持つすべてのテストに+10〜28秒 — 15分のCIシャード2本を timeout-minutes killに追い込むのに十分でした。

中間の道 — 重くて安定した依存だけをバンドルし、それ以外はソース配信を維持する:

// vite.config.ts
const BUNDLED_GENERATORS = process.env.E2E_BUNDLED_GENERATORS === "1";
const GENERATORS_DIST = fileURLToPath(
  new URL("../generators/dist/index.js", import.meta.url),
);
if (BUNDLED_GENERATORS && !existsSync(GENERATORS_DIST)) {
  throw new Error(
    "E2E_BUNDLED_GENERATORS=1 but the bundle is missing — build the package first.",
  );
}

export default defineConfig({
  resolve: {
    // However your setup enables the `development` condition, preserve
    // Vite's defaults — a bare `conditions: ["development"]` REPLACES
    // them (dropping `module`/`browser` and the mode-dependent
    // production condition) and can change dependency resolution in the
    // production build. On Vite 6: spread `defaultClientConditions`.
    conditions: [...defaultClientConditions, "development"],
    ...(BUNDLED_GENERATORS
      ? {
          alias: [
            // Exact-match so hypothetical subpath imports still resolve normally
            { find: /^@acme\/generators$/, replacement: GENERATORS_DIST },
          ],
        }
      : {}),
  },
});
# CI e2e job: build the heavy package (~15s), then run with the flag
- name: Build the bundled-dep e2e lane
  run: pnpm --filter @acme/core build && pnpm --filter @acme/generators build

- name: Run Playwright e2e tests
  env:
    E2E_BUNDLED_GENERATORS: '1'
  run: pnpm exec playwright test

3つの性質が要になっています:

  • existsSync ガードはバンドルの欠如に対してthrowする。 できないことにも注意:古いバンドルは検出できません — 鮮度を保証するのはガードではなく、Playwrightの直前にパッケージをビルドするCIステップです。ガードがなくても dist の欠如は失敗します(エイリアスがインポートを存在しないファイルへ向けるため)が、それは実行途中の分かりにくいモジュールロードエラーとして現れます。ガードはそれを、名前の付いた即時のブート時エラーへ変換します。

  • フラグはオプトイン。 ローカルの pnpm test:e2e とインタラクティブな開発はソース配信とHMRを維持し、CI(およびCIを再現したい人)だけがグラフ圧縮のための約15秒のビルドを払います。

  • バンドルするのは重くて安定したパッケージだけ。 アプリ自身のソース — テスト対象のコード — は引き続きライブで配信されます。バンドルしているのは依存であって、被検体ではありません。エイリアスは意図的に完全一致です:サブパスのインポート(@acme/generators/foo)は通常のソース解決にフォールスルーするため、パッケージがホットパスで使われるサブパスのエントリポイントを公開しているなら、それぞれにもエイリアスを張ってください。

実例のエビデンス:e2eシャード2本が、ランごとに10〜23回の失敗アテンプトを伴う15分 timeout-minutes killの繰り返しから、6分35秒と8分14秒・失敗アテンプトゼロになりました — スイートの過去最良のグリーンと比べても約2倍高速です。

Note

この失敗クラスが「探すべきものだと知る」にどう見えるか — バックグラウンドのモジュールストームが無関係な待機を飢えさせる様子と、ゲートしたときに重いロードが待機間を移動する様子 — は、フレイク根本原因カタログのエントリ6にあります。タイムアウトを越えるポーリングによる証明と、テストごとの実行時間diffも同エントリを参照してください。

隠れた補助ソケットのポートレース

上記のポートレースに関するガイダンスや、実行ティアで扱っているのはアプリケーションポートです。各フィクスチャに専用の port を与えれば、N個並行する webServer エントリ間の EADDRINUSE 衝突は解消します。しかし、アプリケーションポートを区別するだけでは解決しないもう一段階微妙なパターンがあります。

症状: N個の webServer エントリがそれぞれ正しく異なるアプリケーションポートを持っているにもかかわらず、断続的にクラッシュする。しかも失敗が示すポートは playwright.config.ts のどこにも現れません:

Error: listen EADDRINUSE: address already in use :::9229

診断:webServer エントリが起動するdevサーバープロセスは、アプリケーションポートだけを開くわけではありません。devtoolsのインスペクター、HMRのWebSocket、メトリクスエンドポイントといった補助ソケットも開いており、それぞれが設定したアプリケーションポートとは無関係な独自のデフォルトポートを持っています。たとえばNodeのインスペクターは、アプリがどのポートで待ち受けていようと関係なく 9229 にデフォルトで待ち受けます。N個のフィクスチャが並行して起動すると、すべてのインスタンスがその同じ共有デフォルトポートへのバインドを競い合い、負けたインスタンスが EADDRINUSE で落ちます — 設定のどこにも書かれていないポートで。診断の手がかりは、クラッシュしたポートが見覚えのないものであることです。テスト設定内のどの portbaseURL の値とも一致しません。

修正: アプリケーションポート自体と同じように、補助ソケットもOS割り当てのポート(0)に設定します:

# Anti-pattern: --inspect binds the fixed default (9229) regardless of app port
node --inspect ./server.js

# Fix: --inspect=0 binds an OS-assigned port -- no shared default left to race on
node --inspect=0 ./server.js

同じ対処は、devツールが持つあらゆる補助チャネル — HMRのWebSocketポート、メトリクス/テレメトリポート、バンドラー自身のdevtoolsブリッジ — にも当てはまります。ツールが公開している設定ノブが何であれ、組み込みのデフォルトのままにせず 0 を指定してください。

Warning

ブートのスタガー(各フィクスチャの起動を数秒ずつずらすこと)は、このレースの幅を狭めはしますが、閉じきることはできません — これは緩和策であって修正ではありません。3秒のブートスタガーは経験的に信頼できず、並行起動のおよそ2回に1回でこの衝突が再現しました。恒久的な修正は補助ソケットをポート 0 に固定することです。スタガーは、その上に重ねる安価な保険としてのみ使い、唯一の防御策にしてはいけません。

ネイティブスイートにおける「ポートの競合(EADDRINUSE)」の項目(フレイク根本原因カタログ)も参照してください — 根本原因は同じ(並行プロセス間で共有される固定ポート)ですが、ここではアプリケーションの主ポートではなく、devサーバーの見えにくい二次的なソケットに特化しています。

シャードCIラン

大規模なテストスイートの場合、複数のCIランナーにシャードします:

# .github/workflows/e2e.yml
jobs:
  e2e:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: pnpm install
      - run: npx playwright install --with-deps
      - run: npx playwright test --shard=${{ matrix.shard }} --reporter=blob
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: blob-report-${{ strategy.job-index }}
          path: blob-report/
          retention-days: 1

  merge-reports:
    if: always()
    needs: e2e
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: pnpm install
      - uses: actions/download-artifact@v4
        with:
          path: all-blob-reports
          pattern: blob-report-*
          merge-multiple: true
      - run: npx playwright merge-reports --reporter=html ./all-blob-reports
      - uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

Note

各シャードは、デフォルトのHTMLレポートの代わりに blob レポートを書き出すようになりました——別々のシャードのHTMLレポートは結合できませんが、blobレポートなら結合できます。失敗したシャードのレポートこそ最も必要になるため、この失敗ケースでは2つの設定が重要です。マトリクスの fail-fast: false は、1つのシャードが失敗した瞬間に他のシャードがキャンセルされるのを防ぎます。アップロードステップの if: always() は、失敗したシャードのblobレポートがスキップされるのを防ぎます(GitHub Actionsはステップが非ゼロで終了すると、デフォルトで以降のステップをスキップします)。この2つがどちらも無ければ、merge-reports(こちらも if: always() で実行されます)はパスしたシャードのblobレポートしか目にすることができません。

残したいアーティファクトは outputDir の外に置かなければならない

Playwrightは毎回のランの開始時に outputDir を空にします。デフォルトは test-results/ で、このワイプは無条件です:今回のランが上書きするファイルに限定されず、それを誰が書いたかも気にしません。ランが始まる時点でそのディレクトリに置かれていたものは、すべて消えます。

これは正しい振る舞いです——前回のランの失敗で埋まったトレースディレクトリは、役に立たないどころか有害です——そして、何かを残そうとするまでは見えません。

# Run 1 -- writes its report into outputDir
PLAYWRIGHT_JSON_OUTPUT_NAME=test-results/report-1.json npx playwright test

# Run 2 -- a DIFFERENT filename, so surely both survive?
PLAYWRIGHT_JSON_OUTPUT_NAME=test-results/report-2.json npx playwright test

ls test-results/
# .last-run.json  report-2.json
#
# report-1.json is gone. So is anything else that was in there.

Warning

一意なファイル名はアーティファクトを救いません。 上書きされたレポートを発見したときの本能は、名前を一意にすることです——ランのカウンター、タイムスタンプ、gitのSHA。それは役に立ちません。なぜなら、何も上書きされてはいないからです:最初のテストが始まる前に、ディレクトリ全体が空にされているのです。タイムスタンプ方式は毎回ちょうど1つのファイルを生み、うまくいきそうに見える名前の裏へメカニズムを隠します。

修正は命名ではなく配置です。残す・比較するつもりのあるものはすべて、outputDir の外の兄弟ディレクトリへ書いてください:

# Reports accumulate -- run N leaves run N-1's report untouched
PLAYWRIGHT_JSON_OUTPUT_NAME=b4push-reports/report-1.json npx playwright test
PLAYWRIGHT_JSON_OUTPUT_NAME=b4push-reports/report-2.json npx playwright test

ls b4push-reports/
# report-1.json  report-2.json

PLAYWRIGHT_JSON_OUTPUT_NAME設定ディレクトリからの相対パスとして解決され、JSONレポーターは足りない親ディレクトリを自分で作ります——mkdir -p のステップは不要です。兄弟ディレクトリは .gitignore に追加してください。

Note

修正に見えて違う、名指しする価値のある2つのニアミス:

  • preserveOutput はワイプを防ぎません。 名前はまさにこの制御のように読めますが、違います。これはテストごとのアーティファクトサブディレクトリ(トレース、スクリーンショット、ビデオ)をパスしたテストについても保持するかどうかを制御するもので、ラン開始時のクリーン処理にあなたのファイルをスキップさせるものではありません。

  • カスタム outputDir は誤った理由で「うまくいき」ます。 outputDir を別の場所へ向けてもワイプの場所が移るだけで、すべてのトレース、スクリーンショット、ビデオを道連れにします。兄弟ディレクトリが即座に解決する問題と引き換えに、はるかに大きな爆発半径を抱え込むことになります。

なぜこれが本サイトの推奨するクロスランワークフローを直撃するのか

このハザードはローカルファーストです。CIではほぼ観測できません——各ランはフレッシュなランナーを得るため、ラン開始時のワイプが見つけるのは空のディレクトリで、アーティファクトのアップロードステップはいずれにせよレポートを出荷します。表面化するのは2つのランがワーキングツリーを共有した瞬間で、それはまさに本サイトが処方するローカルトリアージの形そのものです:2つのラン間のテストごとの実行時間diffと、「このスペックは両方のランで失敗したか?」を判定するあらゆるツーリング(レーンのオーバーサブスクリプションを参照)。「2回実行して2つのJSONレポートを比較する」の上に組まれたツールは、両方のレポートが test-results/ に書かれる限り構造的に不可能です——しかも静かに失敗します:2回目のランはレポートを1つだけ見つけ、比較するものは無いと結論し、成功を報告するのです。

Warning

インメモリのフィクスチャの上に組まれたセルフテストは、このバグにすでに壊されているツールを認証してしまいます。 実例では、クロスラン・トリアージスクリプトの --self-test はレポートオブジェクトをメモリ内で構築し、パーサーを動かして100%パスしました——その一方で、それが存在する理由であるディスク上のワークフローは、パースすべき2つ目のレポートを決して生み出せなかったのです。greenのセルフテストはエンドツーエンドの検証として読まれ、動きようのないツールが出荷されました。

実際に問題となる継ぎ目——アーティファクトは2回目のランを生き延びるか?——をテストしてください。パーサーだけでは足りません。自分の仮定から組み立てたフィクスチャは、その仮定を確認することしかできないのです。

(Playwright 1.58.2 に対して実際に実行して検証済み:test-results/ に置いたセンチネルファイルは次のランで削除され、兄弟ディレクトリに書いた2つのレポートは共存しました。)

フロントエント専用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 を使う検証経路を扱っています。

Revision History

作成更新