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/ },
  ],
});

この設定では、fixtureA・fixtureB・fixtureC 以外で始まるファイル名のスペックはどのプロジェクトにもマッチせず、ゼロのプロジェクトに収集されます。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自身のネイティブなパーセンテージ構文が、それを受け付けるために書かれたはずの型変換によって破壊されるのです。そして workers が NaN のとき、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.step は1つのテストタイムアウトを共有する

test.step() はレポート用の構造です。HTMLレポートとトレースビューアーの中で、一連の操作に名前付きのノードを与えてグルーピングするものであり、新しいタイムアウト予算を開くわけではありません。test() の中のすべてのステップは、たった1つの同じ期限——設定ファイルで指定した timeout(Playwright自身のデフォルトは30秒ですが、重いセットアップを持つスイートでは日常的に引き上げられます)、あるいはそのテストに対して test.setTimeout() が最後に設定した値——を削り合います。2つのシナリオを2つのステップに入れ子にして得られるのは、整ったレポートだけです。

この区別が牙を剥くのは、ステップが1つのシナリオの各フェーズではなく、独立した逐次的なシナリオそのものであるときだけです:

// Anti-pattern: two independent scenarios sharing one 60s test budget.
test("commit density stays invariant", async ({ page }) => {
  await test.step("free mode", async () => {
    await page.goto("/");
    // fresh app boot + scenario -- budgeted ~20-30s by this file's own comment
  });

  await test.step("fixed mode", async () => {
    await page.goto("/");
    // fresh app boot + scenario -- budgeted ~20-30s by this file's own comment
  });
});

各ステップは完結したシナリオです。自前のナビゲーション、自前のアプリ起動、自前の境界付きの待機を持っています。ファイル自身のコメントは各パスをおよそ20〜30秒と見積もっていましたが、設定のデフォルトはテスト全体で60秒でした。それぞれが宣言した予算の上限近くまで使う2つのパスは、合算するとこれを超過します。そして失敗は2つ目のステップ——個別にはどこもハングしていない場所——で表面化します。Fixedモードのフェーズにある待機はどれも境界付きでイベントキーされており、単に共有予算の残りが少なすぎる状態で始まっただけなのです。

このクラスが失敗メッセージから読み解きにくいのは、まさにこの点です:失敗するステップは、遅いステップではない。 予算を使うのはFreeモードのフェーズで、そのツケを払うのはFixedモードのフェーズです。2つ目のステップ内のどの待機の期限を広げても何も変わりません。発火した期限は親の test() のものだからです。

修正:独立したシナリオごとに1つの test()

2つのステップがそれぞれ単独でも実行できるなら——フレッシュなナビゲーションを持ち、境界をまたいで状態を持ち越さないなら——それは2つのテストです。分割すれば、すでに各ステップが行っていたフレッシュなセットアップを保ったまま、それぞれが予算をまるごと1つ受け取れます:

test("commit density stays invariant -- free mode", async ({ page }) => {
  await page.goto("/");
  // ...
});

test("commit density stays invariant -- fixed mode", async ({ page }) => {
  await page.goto("/");
  // ...
});

pgen -- 非公開のGPUパターンジェネレーター -- のケースをこの形で分割した結果、バンドル版Chromiumでの3回のランで、Freeのタイミングは 22.3 / 17.1 / 21.9秒、Fixedのタイミングは 15.5 / 11.3 / 16.8秒 になりました。いずれも45秒を下回っており、しかもテストごとの予算60秒は一切変えていません。シナリオが遅かったことは一度もなく、遅かったのはその合計だけだったのです。

ステージを本当に分割できない場合——高価な状態を共有している、あるいは後段が前段の生成物に対してアサートしている場合——は、1つの test() に留めたうえで、算術を書き下してください。すべてのステージ期限にセットアップとティアダウンを加えた最悪ケースを合算し、その合計から明示的な test.setTimeout() を導出し、合計値をコメントに残します。そうすれば、次にステージ期限を編集する人が、自分が何を消費しているのかを見て取れます。

Warning

このクラスをタイムアウトの安易な引き上げで解決してはいけません。 ステージの算術に裏打ちされない引き上げは当て推量であり、しかも診断を覆い隠すたぐいの当て推量です。速くて読みやすい「ステップ2がタイムアウトした」という情報を手放し、その代わりに、数分遅くなったうえで、いずれどこかのステージが伸びた日にはやはりタイムアウトするスイートを手にすることになります。数値を引き上げるのは合算の_結果として_だけであり、赤に対する最初の反応としてではありません。

これは、予算が合算で期限を超えるという問題の、Playwrightのテストレベルの形です。ランナーレベルの形——逐次的なステージ期限の合計がnextestやCIのテストごとkill期限を超え、しかもそのkillがテスト自身の診断ログのフラッシュを先取りしてしまうケース——については、フレイク根本原因カタログの逐次ステージ予算の項目を参照してください。

前提条件としての 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 であり、値ボックスは textbox(type="text")または spinbutton(type="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-index、pointer-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行の理由があれば、次に読む人は根本原因が修正されたその日にエントリを削除できます。許容リストが本来あるべき姿――膨らむのではなく縮む――になるのは、まさにそのときなのです。

クロスコンテキストの終端証拠:ブラウザイベントをレースし、ページ診断は Node 側に残す

上のモニターは page.on のイベントを Node 側にバッファリングしています。一般化する価値があるのは、まさにその部分です。テスト対象の結果がブラウザレベルの終端イベント — ダウンロード、ポップアップ、ファイルチューザー — である場合、Playwright はそれを Node 側で観測する一方、それを説明する診断情報はページの中にあり、しかもページの実行コンテキストはまさにその境界で消えてよいことになっています。これに耐える形は、ページ側のブリッジが届けたレコードを到着した瞬間に Node 側へコピーし、加えてブラウザ自身の終端イベントをトリガーの前に登録して、赤を意味するものすべてとレースさせることです。

実例:pgen のエクスポートフロー — ファイルを生成してブラウザに引き渡すダイアログ — は、この 1 つの継ぎ目から 2 つの偽陰性を続けて生み出しました。

同じ継ぎ目から生まれた 2 つの偽陰性

1 つ目はリスナーをトリガーの前に正しくインストールしていましたが、それでもダウンロードを取りこぼしました:

// Listener exists, but page-resident polling can still defeat it.
const download = page.waitForEvent("download");
await start.click();
await expect.poll(() => readDialogStatus(page)).toBe("done");
await download;

download の Promise はクリックの前に登録されており、デフレイキングレシピのステップ1が要求するとおりです。バグはトリガーと消費のあいだの行にあります。readDialogStatus はページを読んでおり、終端の境界でダイアログの実行コンテキストが消えました。ポーリングは Execution context was destroyed を投げ、テストは既に観測済みのダウンロードを手に持ったまま赤になりました。プロダクトは動いていたのに、計測器自身の DOM 読み取りが証明を捨てたのです。

修正版は結果を Node 側に保ちつつ、ステータスを page.exposeFunction 経由で届くページ側の done バインディングに移しました — そのうえで、実際のダウンロードのあと 1 スケジューラーターン以内にそのバインディングが届くことを要求したのです。バインディングの配送は、まさに解体されつつあるその境界を越えます。したがって、その実行を落とすのに十分なだけ遅れることがあります。判定はプロダクトではなく冗長なオブザーバーテレメトリのものになりました。同じ誤りの配置違い — 診断が、それが記述するために存在するはずの結果より上位に立ってしまったのです。

2 度とも継ぎ目は 1 つでした — 結果は Node 側で観測され、証拠は死ぬことを許されたページの中に居座っている、という継ぎ目です。

ブリッジは最初のナビゲーションより前にインストールする

最初のナビゲーションより前、そしてどのトリガーよりも前に、次の順序で 3 つを呼びます:

  1. page.exposeFunction("recordDiagnostic", ...) — Node 側のシンク。ページからは window.recordDiagnostic として到達できます。この方法でインストールされた関数はナビゲーションを越えて生き残ります。

  2. page.addInitScript(installDiagnosticListener) — バインディングを呼ぶページ側のリスナー。init スクリプトはドキュメントが作られたあと、ページ自身のスクリプトが 1 つも走る前に評価され、あらゆるナビゲーションと、アタッチまたはナビゲートされるあらゆる子フレームで再実行されます。

  3. そのうえで初めて page.goto(...) とトリガー。

exposeFunction が先なのは、init スクリプトがドキュメント開始時点でバインディングを呼ぶからです。あとからバインディングをインストールすると、最初のドキュメントのレコードは行き場を失います。再実行という性質は両刃です。page.evaluate でインストールしたリスナーなら消えてしまうナビゲーションのあとでもブリッジは自力で復旧しますが、その代わりページ側は冪等でなければならず、すべてのレコードは自分が属する run id を持たなければなりません。本サイトに 1 つだけある addInitScript の用例 — コネクション飢餓の項にあるpage.goto() の前に仕込む A/B フラグ — も、この「ドキュメントが走る前」という保証に依存しています。

Node 側は run id をキーにした境界付きバッファです:

  • 境界付き — 実行あたりのレコード数の上限と、保持する実行数の上限。古いものから捨てます。おしゃべりなページがテストプロセスを肥大させられてはいけません。

  • キー付き — 放棄された過去の実行のレコードが、あとの実行の契約を満たすことは決してありません。

  • 到着時にコピー — コールバックは配送時点で検証し、ページのハンドルではなく素のスナップショットとして保存します。既に届いたものは、コンテキストの消失では消せません。

// e2e/cross-context-evidence.ts -- observer-only; nothing here is on the product path.
// parseDiagnostic and validateRun are elided; "Validate, then apply the
// evidence-source hierarchy" below is their entire specification.
import type { Page } from "@playwright/test";

const MAX_RECORDS_PER_RUN = 64;
const MAX_RUNS = 4;

export type Diagnostic = {
  v: 1;
  runId: string;
  seq: number;
  stage: "start" | "progress" | "done" | "error" | "cancel";
  reason?: "quota" | "network" | "aborted";
};

export async function installEvidenceBridge(page: Page, runId: string) {
  const runs = new Map<string, Diagnostic[]>();
  const stats = { malformed: 0 };
  let signalTerminal!: (record: Diagnostic) => void;
  const terminal = new Promise<Diagnostic>((resolve) => {
    signalTerminal = resolve;
  });

  // exposeFunction BEFORE addInitScript: the init script calls this binding at
  // document start. It must never throw -- a throw rejects the page-side call and
  // resurfaces as a page error. Malformed input is counted, and counted is red.
  await page.exposeFunction("recordDiagnostic", (raw: unknown) => {
    const record = parseDiagnostic(raw); // version + closed enums, per record
    if (!record) {
      stats.malformed += 1;
      return;
    }
    // Copy on arrival: a snapshot held in Node cannot be erased by context loss.
    const list = runs.get(record.runId) ?? [];
    list.push(record);
    runs.set(record.runId, list.slice(-MAX_RECORDS_PER_RUN)); // bounded per run
    for (const stale of [...runs.keys()].slice(0, -MAX_RUNS)) runs.delete(stale);
    if (record.runId === runId && (record.stage === "error" || record.stage === "cancel")) {
      signalTerminal(record); // resolves the race's active-run error/cancel branch
    }
  });

  // Re-runs on every navigation and every attached frame, so it must be idempotent.
  // The observer stamps the run id: the product never learns it, so a record can
  // only belong to the run whose bridge delivered it.
  await page.addInitScript(({ runId }) => {
    if ((window as any).__evidenceBridgeInstalled) return;
    (window as any).__evidenceBridgeInstalled = true;
    addEventListener("app:export-diagnostic", (event) => {
      // Fire-and-forget: the product path never awaits the bridge.
      void (window as any).recordDiagnostic({ ...(event as CustomEvent).detail, runId });
    });
  }, { runId });

  return { runs, stats, terminal, attached: false };
}

export type Evidence = Awaited<ReturnType<typeof installEvidenceBridge>>;

Note

このブリッジは観測専用です。ページは診断を発火したら先へ進みます。プロダクトの経路が recordDiagnostic を await することはなく、ブラウザ自身のイベントが既に冗長にしたレコードをアサーションが待つこともありません。プロダクトが待たなければならない計装は、もはや計装ではありません — それはテスト時にだけ走る、独自の故障モードを持つ 2 本目のプロダクト経路です。

終端イベントはトリガーの前に登録し、そのうえでレースする

page.waitForEvent("download") はクリックより前に置きます — Playwright 自身のダウンロードガイドもクリックの前に待機を開始しますし、ステップ1が理由を既に導いています。このパターンが足すのはその次です。ページを読むのではなく、4 つのブランチのレースを行います。

  1. 終端イベントそのもの — Playwright が Node 側に届ける download。

  2. アクティブな実行のエラーまたはキャンセルの証拠 — この run id のレコードが失敗ステージを報告したときにバッファが解決する Promise。

  3. ページのクラッシュまたはクローズ。

  4. 1 つだけの絶対デッドライン。

ブランチ 3 はブランチ 1 を救うためのものではありません。page.waitForEvent はページがクラッシュまたはクローズすれば既に reject します。これがあるのは結果に名前を付けるためです。死んだページと失敗したプロダクトは別の判定であり、証拠はそのどちらなのかを述べなければならないからです。

クリーンアップは決定的で、どのブランチが勝っても走ります — デッドラインには clearTimeout、クラッシュとクローズのハンドラーには page.off です。レースより長生きしたタイマーはワーカーのイベントループを起こしたまま、解体済みのフィクスチャに向けて発火します。長生きしたハンドラーは、複数のステップで使い回されるページに積み上がっていきます。そして、レースのどのブランチも .catch(() => null) で包みません。それはステップ3、つまり本物のタイムアウトをサイレントなグリーンに変える行為です。絶対デッドラインは新しい予算ではなく、テスト自身の予算からマージンを引いた値にします — test() の中のすべてのステップは同一の 1 つのタイムアウトを食い合うので、それ以上のデッドラインは決して発火できません。

// e2e/export.spec.ts
import { test, expect } from "@playwright/test";
import { attachEvidenceOnce, installEvidenceBridge, validateRun } from "./cross-context-evidence";

const EXPORT_DEADLINE_MS = 20_000; // sits under this test's own timeout, with margin

test("export delivers a file", async ({ page }, testInfo) => {
  const runId = crypto.randomUUID();
  const evidence = await installEvidenceBridge(page, runId); // before the first navigation
  await page.goto("/export");

  let timer: ReturnType<typeof setTimeout> | undefined;
  let onDead!: () => void;
  const dead = new Promise<never>((_resolve, reject) => {
    onDead = () => reject(new Error("page crashed or closed during export"));
  });
  page.on("crash", onDead);
  page.on("close", onDead);

  try {
    const download = page.waitForEvent("download"); // registered BEFORE the trigger
    await page.getByRole("button", { name: "Export" }).click();

    // Nothing reads the page between the trigger and the race: the execution
    // context is allowed to disappear right here. No branch is ever swallowed.
    const outcome = await Promise.race([
      download.then((value) => ({ kind: "download" as const, value })),
      evidence.terminal.then((record): never => {
        throw new Error(`export reported ${record.stage}: ${record.reason ?? "unspecified"}`);
      }),
      dead,
      new Promise<never>((_resolve, reject) => {
        timer = setTimeout(() => reject(new Error("export deadline exceeded")), EXPORT_DEADLINE_MS);
      }),
    ]);

    validateRun(evidence, runId); // required minimum evidence; malformed is red
    expect(outcome.value.suggestedFilename()).toMatch(/\.zip$/);
    // The page-side "done" record is redundant here: the browser already delivered.
  } finally {
    clearTimeout(timer); // deterministic on every branch, winner or not
    page.off("crash", onDead);
    page.off("close", onDead);
    await attachEvidenceOnce(testInfo, evidence, runId);
  }
});

検証してから、証拠ソースの階層を適用する

検証は 1 つの契約の両面であり、どちらも上の例に名前として現れています。parseDiagnostic は到着した 1 レコードを検査します — バージョンフィールドと、閉じた集合から取られた stage と reason です。validateRun は判定の前に実行全体を検査します — アクティブな run id の start レコードが存在すること、seq が厳密に増加し終端ステージのあとに何も記録されていないこと、そして stats.malformed がゼロであること。どちらも規律を再定義しません。証拠はバージョン付きの契約であることと理由コードは閉じた、漏れない集合であることが、既にそれを定めています。アクティブな実行の証拠が壊れていれば赤です。認識できない stage 文字列こそ、閉じた enum が捕まえるために存在するものであり、それを「たぶん大丈夫」と読むことは、契約が取り除いた曖昧さを呼び戻す行為だからです。なお、上の例が本体を省いているのはこの 2 つの関数だけで、いま挙げた規則がその中身のすべてです。

そこで残るのが、2 つの偽陰性が取り違えた問い — どのソースが決めるのか、です:

証拠のソースこのパターンでの実体判定における重み
権威あるプロダクト/ブラウザの結果Playwright が Node 側に届ける実際の download イベント合格を決めるのはこれ。冗長なソースがこれを覆すことはない
必須の最小限の診断証拠この run id に対する妥当なアクティブ実行の start レコード存在し、かつ妥当でなければ赤
任意の冗長なオブザーバーテレメトリダウンロードのあとに届くページ側の done コールバック到着を必須にしてはならない。ただし内容は依然として拘束する — error や cancel のレコードは赤

Warning

両面を必ず言うこと。片面だけならこの節は「診断を無視せよ」と読まれます。 妥当なアクティブ実行が存在し、ブラウザが実際の終端イベントを届けたなら、その実行は合格です。冗長なページ側の done が同じスケジューラーターンで届く必要はありません — そもそも届かなくても構いません。そのうえで、観測されたエラーやキャンセルのレコード、壊れた証拠、クラッシュやクローズ、絶対デッドラインの超過は、いずれも依然として赤です。冗長なテレメトリの「到着」要求をゆるめることは、それが実際に届いたときに述べる内容をゆるめることではありません。

凍結し、finally で 1 度だけ添付する

証拠は添付の前に凍結します。遅れて起きたナビゲーションで init スクリプトが再実行されれば、添付を書いている最中でもバッファにレコードが積まれえます。そして、自分が記述すると称する実行と一致しない添付は、無いよりも悪いものです。アクティブな実行のレコードをスナップショットし、壊れたレコードを数え、直列化してから添付する — それも 1 度だけ、証拠オブジェクト上のフラグで守って、2 つの出口経路が同じ名前で 2 つの添付を書いてしまわないようにします。

// e2e/cross-context-evidence.ts (continued)
import type { TestInfo } from "@playwright/test";

export async function attachEvidenceOnce(testInfo: TestInfo, evidence: Evidence, runId: string) {
  if (evidence.attached) return; // two exit paths must not write two attachments
  evidence.attached = true;
  // Freeze first: a late init-script re-run must not mutate what this describes.
  const frozen = JSON.stringify({
    runId,
    malformed: evidence.stats.malformed,
    records: [...(evidence.runs.get(runId) ?? [])],
  });
  try {
    await testInfo.attach("export-evidence.json", { body: frozen, contentType: "application/json" });
  } catch (attachError) {
    // Never rethrow from finally: that would replace the failure under investigation.
    testInfo.annotations.push({ type: "evidence-attach-failed", description: String(attachError) });
  }
}

ヘルパー内部の try/catch こそが本質的に重要な部分です。finally ブロックが例外を投げると、テストがまさに報告しようとしていたエラーがストレージの苦情に置き換わり、元の失敗は消えます。添付は独自の判定を持つ独立した関心事であり、アノテーションとして記録され、実行そのものの判定を覆い隠すことは決して許されません — teardown の 3 層で、クリーンアップがプロダクトの判定に畳み込まれず自前の判定を報告するのと同じ分離です。そして添付は、依然としてランナーのディスクに書かれただけのものです。ジョブが終わったあとに誰かが読める証拠になるには、トレースを記録しても残したことにはならないのアップロードゲートが必要です。

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

Takazudo Modular(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 で落ちます — 設定のどこにも書かれていないポートで。診断の手がかりは、クラッシュしたポートが見覚えのないものであることです。テスト設定内のどの port や baseURL の値とも一致しません。

修正: アプリケーションポート自体と同じように、補助ソケットも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サーバーの見えにくい二次的なソケットに特化しています。

fullyParallel の既定は false — メガファイルが作るテール

workers の値は、ランがいくつの並列レーンを持つかを決めます。fullyParallel はそのレーンに何を入れてよいかを決めるもので、その既定値は多くの人が想定するより狭いものです:既定は false であり、ファイル 単位では並列化する一方、1つのファイル 内 のテストはすべて単一のワーカーへ直列化されます。 並行実行の単位はテストではなくファイルなのです。

これによって、スイートの実測時間は割り算ではなく最大値になります:

critical path = max(slowest single file, total test time ÷ workers)

コアを買って反応するのは第2項だけです。第1項はまったく動きません — つまり total ÷ workers が最も遅いファイルを下回った瞬間から、ワーカーを追加しても何も買えなくなり、スイートの下限は1つのファイルの実行時間になります。

テスト時間の合計が92分 — スイート全体の予算が5,520ワーカー秒と価格付けした46分・2ワーカーのステップを、実測時間から仕事量に換算し直した値 — で、上位2ファイルが10:08と9:41だった実在のスイートで見てみます:

 2 workers:  max(10:08, 92:00 ÷  2 = 46:00)  =  46:00   ← the division dominates
 4 workers:  max(10:08, 92:00 ÷  4 = 23:00)  =  23:00   ← still the division
 8 workers:  max(10:08, 92:00 ÷  8 = 11:30)  =  11:30   ← nearly a tie
16 workers:  max(10:08, 92:00 ÷ 16 =  5:45)  =  10:08   ← the file is the floor

このスイートは、8ワーカーと16ワーカーのあいだのどこかで、ワーカー律速であることをやめてファイル律速になります。その点を越えて大きなランナーに支払っても、買えるのは2倍の分単価と、ほとんど動かない実測時間だけです — CIランナーのサイジング ルール1が警告しているのと同じ失敗が、明示的な workers の上限ではなく設定の既定値を通じてここに現れます。

Warning

テストを少数のファイルへ集約すると、これは悪化します。しかも集約は、それ自体としては良い衛生です。 関連する6つのスペックを1つのパラメトライズされたメガファイルへマージすれば、重複したセットアップは消え、ファイル一覧は短くなり、読みやすくもなります — そして、あの max の第1項を押し上げます。純粋に整理上の作業に見えるリファクタが、アサーションもワーカー数も1つも触らないままスイートの下限を引き上げうるのです。リポジトリが大きなパラメトライズドスペックへ向かう傾向にあるなら、スイートがワーカー律速だと結論する前に、ファイルごとの実行時間を読んでください。

レバーは fullyParallel で、スイート全体にも、ファイル単位にもかけられます:

// playwright.config.ts -- every test becomes its own unit of concurrency
export default defineConfig({
  fullyParallel: true,
});
// e2e/checkout-flow.spec.ts -- opt one genuinely coupled file back OUT
import { test } from "@playwright/test";

// These tests build on a cart seeded by the first one and must run in order.
test.describe.configure({ mode: "serial" });

逆向きも効きます。設定の既定を false のままにしつつ、1つのメガファイルの中に test.describe.configure({ mode: "parallel" }) を置けば、そのファイルだけをワーカー間へ分割できます。テールになっているファイルがちょうど1つか2つのときは、こちらの方が小さく的を絞った手です。

推奨ではなく、実験としてフリップする

fullyParallel: true は、見つけ次第適用してよい設定改善ではありません。これは、スイートがタダで受け取っていた順序の保証を取り除きます — ファイル内のテストは、誰かがそう意図したかどうかに関係なく、ソース順に1件ずつ実行されていました — そして、それに黙って依存していたテストは、大きな音を立てて壊れるのではなく、断続的に落ちはじめます。

ですから、これはマッチした実験として走らせ、結果は2つの受け入れ基準に照らし、その両方が通ることを要求してください:

  1. 実測時間。 after側のクリティカルパスジョブ時間の最大値が、ゲートにしている天井を下回っていること。比べるのは中央値ではなく最大値です:PRの作者が実際に待たされるのは、最も遅い試行だからです。

  2. リトライ。 リトライ回数とリトライマーカーが増えていないこと。リトライバジェットを支払って実測時間を買うフリップは、何も買っていません — 測れるコストを、測れないコストへ振り替えただけです。

実験は通常どおりの統制で誠実に保ちます:変更するのはきっかり1行、lockfile・ランナーの形状・シャードマトリクス・選択されたテスト件数は固定、最初のパスでは直列のオプトアウトを一切取らず、各サイドで最低5回の試行を回すこと。片側1回の試行が測っているのはノイズです。

反例:天井は下回り、それでも却下された

178テストの集約済みスイート(Playwright 1.59.1、4 vCPUランナー上の2 CIワーカー、固定の3シャードマトリクス)が、まさにこの実験を走らせました — 変更は1行、beforeを5試行、afterを5試行、選択件数は同じ、直列のオプトアウトなし:

サンプル試行ごとの最大ジョブ秒数中央値 / 最大
before365, 441, 380, 313, 350365s / 441s (7m21s)
after331, 484, 362, 423, 364364s / 484s (8m04s)

実測時間の基準:合格。 after側の最大値である484s(8m04s)は、9m24sの天井を下回りました。ここでの「合格」が何を意味し、何を意味しないかに注意してください — 中央値は365s → 364s、最大値は441s → 484sへ動いており、このフリップは測れる速度をまったく買っていません。予算を破らなかった、というだけです。

リトライの基準:不合格。 明示的なリトライマーカーは合計2件から合計4件へ増え、試行あたりのリトライ回数の中央値は0 → 1へ動きました。

自然な次の一手 — 問題のファイルを mode: "serial" で除外して再実行する — には、狙う的がありませんでした。マーカーは両方のサイドで再発した command-palette-nested-pick.spec.ts の一般的なノイズと、composer-overlay-crisp-862.spec.ts の1件だけで、特定のファイルを指し示す順序依存や共有状態のシグネチャは繰り返し現れませんでした。除外すべきファイルの、筋の通った集合が存在しなかったのです。

fullyParallel は false へ戻されました。これは、機械的なルールが設計どおりに働いた姿です:基準は2つ、1つは合格、1つは不合格、ゆえに変更は取り込まれません。この瞬間の誘惑は、それでも受け入れてしまうこと — 天井は下回っているし、5試行で4件のリトライマーカーならノイズに見える — であり、それこそがスイートが誰も選んでいないリトライバジェットを抱え込む経路です。CIが吸収したリトライも、リトライ回数ゼロのローカルゲートが露わにする失敗であることに変わりはありません。フレイキーテストの隔離:リトライ回数の非対称性という罠を参照してください。

Note

ここでの却下は、永遠の却下ではありません。 この結果が言っているのは、このスイートは、このテスト件数で、このランナーの形状では、このレバーが切るために存在するファイル内テールを持っていなかった、ということです — 178テストはすでに十分な数のファイルへ散らばっており、ファイル単位の直列化は律速制約ではありませんでした。実験をいつ再実行すべきかを教えてくれるのは、このセクション冒頭の算術です:集約リファクタのあと、大きなパラメトライズドスペックが入ったあと、あるいはワーカー数の増加が total ÷ workers を最も遅いファイルの実行時間より下へ押し下げたあと。

テストごとのフィクスチャ起動に値札を付ける

遅いスイートは「どのテストが遅いのか?」として調査されます — テストごとの実行時間でソートされ、最悪の常習犯が先頭に来るレポートです。しかしこの問いは、多くのスイートにおいて最大の費目を構造的に見つけられません。最大の費目は一様だからです。すべてのテストが支払うセットアップは外れ値としては現れず、あらゆる場所のベースラインを同じだけ持ち上げます。それは「このアプリはただ遅い」として読まれます。

代わりに、まっすぐ値付けしてください:

fixture cost = per-invocation cost × invocation count

この形は2つあり、それぞれ別々に値付けする価値があります。

beforeEach の中の隔離ヘルパー — goto("/") → localStorage/sessionStorage のクリア → reload() — は、テスト自身が求めてもいないテストあたり2回のフルアプリケーション起動を要します:1ラン分ではおよそ1,132回の回避可能な起動です。すべてのテストが等しく支払うがゆえに、テストごとのプロファイルでは見えないままになります。

セットアップヘルパーも同じ算術で値付けできます。しかもこちらは、実際に測れる数字が揃います。代表的なテスト1件はPlaywrightの実測時間で32.8s(外側では34.33s)を要し、DEBUG付きの再実行はそのうちおよそ10.1sをヘルパーの起動部分に帰属させました — 内訳はおよそ7.0sのナビゲーションと、ステップレールが可視になるのを待つおよそ2.0sです。静的な監査では、35のスペックファイルにまたがる77箇所の呼び出しが見つかりました:

10.1s per call  ×  77 call sites  =  777.7s  =  13.0 serial minutes

Warning

これはコンポーネントの価格であって、実測時間の予測ではありません。 777.7sは1つのコンポーネントの直列換算コストです。スイートは複数のワーカーと複数のシャードにまたがって走り、それらは重なり合うため、このコンポーネントを取り除いてもランから13分が引かれるわけではありません。この数字の仕事は順位付けです — このヘルパー1つが、スイート内のどの個別の遅いテストよりも重い、と教えてくれます。テストごとのプロファイルでは決して浮かび上がらなかった事実です。実測時間の予測は before/after のランを測って行い、コンポーネントの価格を引き算して行ってはいけません。

クリアヘルパーがカーゴカルトでないか確かめる

Playwrightの既定の page フィクスチャは、すでにすべてのテストへまっさらな BrowserContext を手渡しています:専用のクッキージャー、専用の localStorage と sessionStorage、どちらも空です。ストレージがテスト間で漏れるのは、意図的にそう仕組んだ場合だけです — テスト間で使い回されるワーカースコープのコンテキスト、手で作ったコンテキスト、意図してロードした storageState。既定のフィクスチャに対してストレージをクリアする beforeEach は、すでに空のものをクリアしています。

この種のヘルパーは、悪意なく生まれます。別のハーネスから持ち込まれた同一コンテキストの前提のもとで書かれたり、本当にコンテキストを使い回していたプロジェクトから丸ごとコピーされたり、フレイクを追いかけている最中に足されて、フレイクが止まったのでそのまま残されたりします。そうした出自はどれもコードには読み取れず、誰かが疑問を持つ頃には、そのヘルパーは77箇所から呼ばれています。

安価な実験が、何かを作りはじめる前にこの問いへ答えます:

# Delete the clear step, then run only the specs most likely to have depended on it.
npx playwright test e2e/seed-heavy-*.spec.ts --repeat-each=3

狙うのはシード量の多いスペック — ストレージへ書き込むもの — であって、スイート全体ではありません。欲しいのは速い答えであって、夜間バッチではないからです。繰り返しても緑のままなら、そのクリアはカーゴカルトでした。答えのコストはコマンド1本です。クリアを「正しく」やるための addInitScript フィクスチャを作れば、コストは1日、しかも自分が抱えていると確認したことすらない問いに答えることになります。

罠:ヘルパーの goto がそのスペック唯一のナビゲーションであることが多い

「ヘルパーをただ消せばいい」で話が壊れるのは、この種のヘルパーがナビゲートするからです:

// e2e/helpers/isolation.ts
import { Page } from "@playwright/test";

export async function resetApp(page: Page) {
  await page.goto("/"); // boot #1 -- and the spec's ONLY navigation
  await page.evaluate(() => localStorage.clear());
  await page.reload(); // boot #2
}
// e2e/settings.spec.ts -- note the absence of any goto of its own
import { test, expect } from "@playwright/test";
import { resetApp } from "./helpers/isolation";

test.beforeEach(async ({ page }) => {
  await resetApp(page);
});

test("saves the theme", async ({ page }) => {
  await page.getByRole("button", { name: "Dark" }).click();
  await expect(page.getByTestId("theme")).toHaveText("dark");
});

resetApp を消すと、このスペックはどこへもナビゲートしなくなります。about:blank の上でロケーターのタイムアウトとして失敗し、その姿は「ストレージのクリアを消した」とはまるで似ておらず、同じ習慣を受け継いだすべてのスペックで同じ失敗が起きます。ナビゲーションという副作用こそが事実上の契約です — ヘルパーが何を名前にしているかとは無関係に。

ですから、この除去は削除ではなく分解です:ナビゲーションは残し、値付けしたステップだけを落としてください。

Note

シードのステップの中には、それ自体がテストであるものがあります。 それを死荷重と見分けることが仕事の全体であり、値付けの算術はそれを代わりにやってはくれません。実例では、サイドバーのマイグレーションのスペックが、マイグレーション経路を検証するために意図して古いストレージをシードしてリロードしていました — それらにとって、シードとリロードはアサーションが依存するセットアップであり、これらのステップは残されました。同じ監査にあった460件の waitForLayoutFlush 呼び出しも同様に、算術を根拠に一括削除されることはありませんでした:1件ごとの除去には、影響を受けるバッチが緑であるランがまず必要です。それが上のWait負債をラチェットで削減するで説明したラチェットです。コンポーネントに値付けするのは、何を調査するかを決めるため。個々の除去は、ランで証明してください。

ドメインごとに正となるセットアップ抽象を1つだけ

前のセクションは、セットアップヘルパー1つに値札を付ける話でした。ここで扱うのは、同じドメインに対してそれが2つあるスイートです。どちらにも言い分があり、どちらも明らかな間違いではありません。

どちらも悪意なく生まれます。関数ヘルパーが先に来るのは、それが最も安く書けるからであり、消費者はそこへ積み上がっていきます — 監査対象のスイートでは101件でした。PageObjectのクラスは後から来ます。複数ステップのフローはクラスの方が読みやすいからで、そちらには11件が集まりました。肝心なのは、クラスが起動を実装し直してはいないことです。クラスは関数ヘルパーへ委譲します。この委譲こそが、この配置を安全に見せます:起動経路は1つ、その綴りが2通りあるだけ、というわけです。

安全でない理由も、まさにその委譲です。2つのサーフェスが等価なのはクラスが転送している部分だけであり、ドリフトは転送していない部分から入り込みます。

// e2e/helpers/composer.ts -- surface A, 101 consumers
import { Page, expect } from "@playwright/test";

export async function openComposerWithTemplate(page: Page, template: string) {
  await page.goto(`/composer?template=${template}`);
  await expect(page.getByTestId("step-rail")).toBeVisible();
}
// e2e/fixtures/composer-page-object.ts -- surface B, 11 consumers, boot delegated to A
import { Page } from "@playwright/test";
import { openComposerWithTemplate } from "../helpers/composer";

export class ComposerPageObject {
  constructor(private readonly page: Page) {}

  async open(template: string) {
    await openComposerWithTemplate(this.page, template); // delegation -- the shared half
  }

  async selectVariant(name: string) {
    await this.page.getByRole("tab", { name }).click(); // behavior -- the unshared half
  }
}

サーフェスAに準備完了のステップを足す — ステップレールだけでなく、キャンバスの描画も待つ — と、クラスの消費者もそれを継承します。同じステップを、たまたま開いていたのがそのファイルだったからという理由で ComposerPageObject.open の側に足せば、101件の関数消費者はそれを受け取りません。どちらにせよ、レビューが見たのは正しそうに見える1つの差分です。両方のスタイルをインポートしているスペックはさらに厄介で、その起動契約はどちらの呼び出しが先に走るかに依存します。それは誰かが決めた何かではなく、そのスペックの行順という性質にすぎません。

両方のサーフェスを立たせたままにするコストはレビューのコストであり、スイートの規模に比例して膨らみます。起動ヘルパーに値札を付けたのと同じ監査は、35のスペックファイルにまたがる77件の起動ヘルパー呼び出しと、460件の waitForLayoutFlush 呼び出しを数えていました。この規模になると、「このスペックは残り76件と同じように起動しているか?」は、読んで答えるのがすでに難しい問いです。セットアップのサーフェスが2つあれば、カバレッジは倍にならないまま、この問いだけが倍になります。

小さい方のサーフェスを大きい方へ畳み込む

  1. それぞれのサーフェスの消費者を棚卸しする。 数えるのは呼び出し箇所ではなくスペックファイルです — 実行もレビューもファイル単位で行うことになるからです。grep -rl を2回走らせればこの先のすべてを決める数字が出ますし、かかるのは1分です。

  2. 関数かクラスか、方向は消費者の数に選ばせる。 101対11は設計論争ではなく、どれだけの移行を引き受けるかについての算術です。たとえもう一方の方が優れた抽象であっても、すでに多くの消費者が使っているサーフェスを選んでください — 好みに払う価値があるのは11件の移行であって、101件ではありません。

  3. 小さい方のサーフェスの有用なメソッドを、勝った側へ畳み込む。 クラスは純粋な無駄ではありませんでした。委譲するだけのメソッドはノイズですが、実際の振る舞いを持つもの — selectVariant や、関数モジュール側がついに育てなかったもの — は、そのクラスが存在した理由そのものです。それらは関数として移し、落とすのは委譲だけにしてください。

  4. 何かに手を付ける前に、両方をインポートしているスペックを探す。 移行が実際に壊れるのはこの両方を使っているスペックであり、しかもどちらのサーフェスの消費者リストもそれを普通の消費者として1件計上するため、リストを眺めているかぎり見えません。

  5. 移行したすべての消費者を一括で走らせて緑にするまで、古いモジュールを削除しない。 古いモジュールがまだツリーに残っている間なら、移行漏れは新しい経路の上で大きな音を立てて失敗します。削除したあとでは、同じ間違いは無関係なファイルの解決エラーとして — あるいはもっと悪いことに、どのプロジェクトにも静かに一致しなくなり、二度と実行されないスペックとして — やってきます。

# Steps 1 and 4: consumer counts per surface, then the specs importing BOTH.
grep -rl "helpers/composer" e2e --include="*.spec.ts" | wc -l
grep -rl "ComposerPageObject" e2e --include="*.spec.ts" | wc -l
grep -rl "ComposerPageObject" e2e --include="*.spec.ts" \
  | xargs grep -l "helpers/composer"
# Step 5: every migrated spec, in one batch, while the old module still exists.
npx playwright test $(git diff --name-only main...HEAD -- 'e2e/**/*.spec.ts')

Warning

移行を隠すためだけに3つ目の抽象を導入してはいけません。 ステップ3で誘惑してくるのは、古い2つのサーフェスの両方から呼べる中立のラッパーです — 今日はどの消費者も変えなくて済む、というわけです。しかしこれは、終わりのある畳み込みを恒久的な3サーフェス体制へ変えてしまいます:準備完了の変更はラッパーへ着地しはじめ、元の2つのサーフェスは削除を強制するものがもう何もないためツリーに残り、閉じようとしていたドリフトは隠れ場所を1つ増やします。畳み込みが完了するのはモジュールが削除されたときであって、モジュールが1つ増えたときではありません。

準備完了のはしごは1つ、記述も1箇所

畳み込みが守っているのは、起動・ナビゲーション・準備完了の契約です。そしてその契約は、正となるモジュールの中に置かれた1つの順序付きのはしごとして書き出す価値があります:

// e2e/helpers/composer.ts -- one ladder, one file, after the fold
export async function openComposerWithTemplate(page: Page, template: string) {
  await page.goto(`/composer?template=${template}`); // 1. navigate
  await expect(page.getByTestId("app-shell")).toBeVisible(); // 2. shell mounted
  await expect(page.getByTestId("step-rail")).toBeVisible(); // 3. domain ready
}

1度だけ書かれていれば、準備完了の変更はすべての消費者が継承する1つの差分になり、レビューの問いは「このスペックは2つのはしごのどちらを受け取ったのか?」ではなく「このはしごは正しいか?」になります。ナビゲーションを明示的な段としてはしごに残しておくことには、もう1つ理由があります:それはテストごとのフィクスチャ起動に値札を付けるが、人が事故で消してしまうと示したステップであり、どこにも書かれていない段は、レビューで誰も守らない段だからです。

Note

実例。 pgenのWave-4統合はこの畳み込みを実行しました:クラスの有用なメソッドは関数ヘルパーのモジュールへ移り、競合していたフィクスチャのモジュールは取り除かれ、e2e/helpers/composer.ts が正となるセットアップサーフェスになりました。それを検証したのは、畳み込み後の選択件数を、プロジェクトごとにテスト数/ファイル数として読んだ数字です — default 178/63、heavy 104/36、GPU 167/38、all-selected 449/129。件数は、畳み込みが消費者を移したのであって失ったのではないことを確かめるチェックです:どこかのプロジェクトの選択からスペックが静かに落ちる移行は、欠けたカバレッジが問題になるその瞬間まで、きれいで速くなったスイートとして読めてしまいます。始める前に記録し、合計ではなくプロジェクトごとに比較し、説明のつかない減少はいずれのプロジェクトにも一致しないスペックを防ぐがゼロを扱うのと同じように扱ってください。

シャード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/
          if-no-files-found: error   # default is warn: a shard that wrote no blob report must go red
          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/
          if-no-files-found: error   # default is warn: an empty merged report must go red, not green
          retention-days: 14

Note

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

コンポジットアクションが所有するのはセットアップであって、テストの選択ではない

ほとんど同じ形をしたe2eジョブ——軽量なシャードマトリクスと、独立した @heavy レーン——を見ると、ジョブ本体をまるごと1つのコンポジットアクションへ畳み込みたくなります。畳み込むのはセットアップだけにしてください。pnpmとNodeのセットアップ、pnpm install、ブラウザのインストール、ビルドは、どちらの呼び出し元でも同一であり、共有の .github/actions/setup-e2e/action.yml に置くべきものです。テストの選択はそこに同行しません:コンポジットアクションはセットアップを所有できますが、呼び出し元がそのあとに実行するコマンドの引数を安全に供給することはできないからです。選択文字列をコンポジット経由で渡すと、1つのファイルに所有者が2人できてしまい、どちらかのレーンが実行対象を変えるたびに、もう一方のレーンも依存しているファイルを編集することになります。

各レーンの選択文字列は、そのレーンが所有する1つのソースだけに置いてください。実例では、heavyレーンは自分自身の scripts/ci/e2e-heavy-args.sh からNUL区切りの引数を取得してPlaywrightを自分で起動し、軽量マトリクスは固定の3シャードのコマンドをその隣のワークフローに保持しています。どちらの呼び出し元も同じセットアップコンポジットを使いますが、一方が他方の選択を黙って変えることはできません。

残したいアーティファクトは 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つのレポートは共存しました。)

トレースを記録しても残したことにはならない

trace: 'on-first-retry' と screenshot: 'only-on-failure' は証拠をランナーのディスクへ書き出しますが、そのランナーはジョブが終わると削除されます。その前に証拠をアップロードするステップが無ければ、この設定は捨てるためだけにトレースを記録する時間を払っていることになります——しかも、ランの見た目には何もおかしなところがありません。ジョブは本物の失敗できちんと赤くなり、空なのはアーティファクトの一覧だけで、その一覧は必要になるまで誰も見ないのです。

計測値:あるPRゲートは trace: 'retain-on-failure' と screenshot: 'only-on-failure' を何か月も掲げながら、ワークフローのどこにも actions/upload-artifact ステップを持っていませんでした。のちのフレーク調査は、CIがすでに捕捉して捨てていた失敗を再現するためにスイートを再実行し、92回のテスト実行という再構築コストを支払いました。

リテンションは、あとから付け足す運用上の細部ではなくT1の定義の一部です——T0 + T1: デフォルトを参照してください。以下では、正しく見えるアップロードがそれでも何も残さない3つの経路——どれも静かに失敗します——と、そのうえで何をアップロードするかを決めるストレージの上限を扱います。

CIのデフォルトレポーターは playwright-report/ を書かない

Playwrightのデフォルトレポーターは、ローカルでは list、CIでは dot です。どちらもHTMLレポートを書かないため、レポーターを明示的に設定しない限り playwright-report/ というディレクトリは存在しません。そのパスを指したアップロードステップは何もアップロードしません:actions/upload-artifact は「No files were found」という警告をログに出して成功するので、ワークフローはグリーンのまま、証拠の欠落は見えないままになります。

デフォルトで失敗の証拠が置かれるディレクトリは outputDir、つまり test-results/ であり、これはステップの作業ディレクトリではなく設定ファイルのあるディレクトリからの相対で解決されます。リポジトリルートから npx playwright test -c apps/web/playwright.config.ts を実行するモノレポのジョブは apps/web/test-results/ へ書き出すため、ルート直下の test-results/ を指したアップロードステップは何も見つけられず、それでも成功します。

だからこそ、どちらかを意図して選んでください:reporter: [['html', { open: 'never' }]] を設定して playwright-report/ をアップロードするか、test-results/ を設定ファイル基準の正しいパスでアップロードするか。決してうまくいかないのは、どのレポーターにも書けと指示していないデフォルト名のディレクトリをアップロードすることです。

アップロードを failure() || cancelled() でゲートする

- uses: actions/upload-artifact@v4
  if: failure() || cancelled()
  with:
    name: playwright-report-${{ strategy.job-index }}-${{ github.run_attempt }}
    path: playwright-report/
    if-no-files-found: error   # default is warn: an upload that finds nothing must go red, not green
    retention-days: 2

if: を持たないアップロードステップは、先行するステップが非ゼロで終了した瞬間にスキップされます——それは、アーティファクトが存在する理由であるすべてのランです。if: failure() はそこを直しますが、もう半分を取りこぼします:timeout-minutes の上限で殺されたジョブや、新しいプッシュが来たときに concurrency グループでキャンセルされたジョブは、失敗ではなくキャンセルであり、そのキャンセルこそが証拠消失そのもののケースです。最もトレースが欲しいランが、静かにアーティファクトを1つも残さないランになります。if: always() も両方をカバーしますが、グリーンのランごとにレポートをアップロードするコストを払うことになります。actions/upload-artifact@v4 の既定は warn なので、前節の「playwright-report/ が存在しない」ケースは「No files were found」とログを出して終了コード0になります——それを赤にするのが if-no-files-found: error です。

2回目のPlaywright実行が、アップロードの前に outputDir を空にする

Playwrightは毎回のランの開始時に outputDir を空にします——前の節のとおりです。したがってPlaywrightを2回起動するジョブ、つまりCIセーフなレーンのあとに @flaky の隔離レーンが続くジョブでは、2回目の起動が1回目の起動のトレースzipと失敗スクリーンショットを、アップロードステップが走るより前に削除します。アップロードは上のすべてのルールを満たしていてなお、隔離レーンの出力しか出荷できません。

実例:ある e2e-full ジョブは pnpm test:e2e:ci:json を実行し、次に npx playwright test --grep @flaky を実行し、最後に playwright-report/ と test-results/ の両方を対象とする1つの actions/upload-artifact を if: always() で走らせていました。リテンションのルールはすべて満たされており、それでもCIセーフなレーンの証拠は消えていました。そのJSONレポートが生き延びたのは偶然にすぎません——PLAYWRIGHT_JSON_OUTPUT_NAME がそれらを playwright-report/ へ迂回させており、そこはPlaywrightがクリアしない場所だったからです。

Warning

正しいアップロードステップは、別のランが空にしたディレクトリからアーティファクトを救い出せません。 これは「一意なファイル名はアーティファクトを救いません」という警告の、1つ上の階層です。空のアーティファクトを見つけたときの本能はアップロードステップを検分することですが、アップロードステップは正しいのです——ワイプは2つのレーンのあいだ、どのアップロードよりも前に起きています。

修正は、起動ごとに専用の --output を与え、両方をアップロードし、さらに、あとの編集が2つのパスを黙って1つへ畳み込めないようにする1行のガードを置くことです:

e2e-full:
  runs-on: ubuntu-latest
  env:
    CI_OUTPUT: test-results-ci
    FLAKY_OUTPUT: test-results-flaky
  steps:
    - name: Each Playwright invocation needs its own --output
      run: '[ "$CI_OUTPUT" != "$FLAKY_OUTPUT" ]'
    - run: pnpm test:e2e:ci:json --output="$CI_OUTPUT"
    - run: npx playwright test --grep @flaky --output="$FLAKY_OUTPUT"
    - uses: actions/upload-artifact@v4
      if: failure() || cancelled()
      with:
        name: e2e-evidence-${{ github.run_attempt }}
        path: |
          ${{ env.CI_OUTPUT }}/
          ${{ env.FLAKY_OUTPUT }}/
        if-no-files-found: error   # default is warn; fires only when every listed path is empty
        retention-days: 2

このガードは最初に走り、コストはゼロです。1行を割く価値があるのは、それが防ぐ失敗が目に見えないからです:両方のレーンを test-results/ へ戻して向けても、ワークフローはグリーンになり、それらしいアーティファクトが1つ添付されます。

そのアップロードの if-no-files-found: error が発火するのは、列挙したパスがすべて空だったときだけです。捕まえられるのは、どちらのレーンも何も書かなかった全損のケースであり、隔離レーンの出力だけがアップロードされてランがグリーンのままになる、部分的な取りこぼしではありません。

ここでの path: は、2レーンの仕組みを見えるようにするために生の出力ディレクトリをアップロードしています — このまま丸写ししないでください。下の「ストレージ上限」サブセクションの実測値では、生のディレクトリは最初の失敗ランでストレージ許容量を吹き飛ばします。呼び出しごとの --output 分離は保ったまま、アップロードするのは自己完結なHTMLレポートにしてください。

潜在形も名指ししておく価値があります。実例では @flaky は1つのスペックにも一致しておらず、これで証拠を失った人はまだ誰もいませんでした——それは救いではなく、まさに要点です。今日は何にも一致しない隔離レーンは、本物の夜間失敗がトレースを必要とするその夜から、トレースを壊し始めます。T1のチェックリスト行は1文で足ります:ジョブがPlaywrightを2回以上起動するなら、起動ごとに専用の --output を与えること。

ストレージ上限:生ディレクトリではなくレポートをアップロードする

生の test-results/ を残すのはタダではありません。3シャードのe2eジョブでの計測値:失敗したシャード1つあたり100–615 MB、3シャード合計で最大1,845 MB、これに対してActionsのストレージ枠は0.5 GBです。生ディレクトリを2日間だけ保持する案でさえ、この数字を前にきっぱり却下されました。

中間の道は、自己完結型のHTMLレポートだけをアップロードすることです——それは初回リトライのトレースと失敗スクリーンショットを内包しており、トリアージのセッションが実際に開くのはそこだからです。短い retention-days を付け、failure() || cancelled() でゲートし、シャードとランで一意な名前にします。

Note

これは完全な保証ではなく中間の道なので、留保が2つあります:

  • HTMLレポーターは onEnd で書き出します。 ハードなタイムアウトによるkillは onEnd に到達する前にプロセスを終わらせるため、アップロードすべきレポートがそもそも生まれません。この種の失敗はジョブログから診断するしかありません。

  • retention-days はアップロードサイズの上限にはなりません。 それが縛るのはアーティファクトを保管する期間、つまり時間をまたいだストレージ料金であって、1回の失敗したランが押し込むバイト数ではありません。フィルタしていない生ディレクトリは、それが走った日にやはり枠を超えます。

フロントエント専用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

作成更新