フレイク根本原因カタログ & デフレイキングレシピ
flakyテストを支配する3つのルール、その背後にある根本原因カタログ、それらを排除するための5ステップのレシピ -- 4つの機械的な修正とエスカレーションの1ステップ -- そして自分のものではない赤いCIに対するゲートオペレーターのプレイブック。
3つのルール
以下はすべて「仕組み」です。これらはその仕組みが仕えるべき「ルール」です — 最初に読んでください。このガイドから1ページだけ覚えるなら、このページを覚えてください。
ルール1 — 「flaky」は診断ではない。まだ診断できていないことの告白である。
ランダムにパス/失敗するテストを決して受け入れないでください。まず証明すること。 「flaky」という言葉は思考停止のラベルです。「なぜ失敗するのか分からない」を、静かに「問題ない」にすり替えてしまいます。この2つはまったく別の文です。
証明の手続きは安価で機械的なので、飛ばす言い訳は存在しません — 失敗した実行とパスした実行で、プロダクトの入力を差分してください:
入力が同一で、タイミングだけが違う → 本物のflakeです。下記のカタログに沿って作業してください。
入力が異なる → プロダクトのバグです。止まってください。プロダクトを直してください。偽物を参照。
ルール2 — 「flakyだから仕方ない」は決して許容されない。受け入れたflakeは、すべてredの価値を下げる。
分かりやすいコストは「CIを永遠に再実行し続けること」です。しかしそれは安い方です。高くつくのはこちらです:
「とりあえず再実行」が当たり前になった時点で、チームは — そしてそのリポジトリで作業するすべてのエージェントは — redを信じないように訓練されてしまいます。
その結果、100%失敗する本物のリグレッションですら、誰かが真に受けるまでに3回再実行され、30%の確率で失敗する本物のバグは文字通り見えなくなります — いつものflakeとまったく同じに見えるからです。
テストスイートの価値のすべては、redが意味を持つという一点にあります。flakeへの寛容は、その性質そのものへの攻撃であり、しかも複利で効いてきます。だからこそpass-on-retryは成功ではなくトリアージのシグナルなのです:記録すべきものであって、祝うものではありません。
このルールより下流にあるこのページの内容は、すべてテストの作者に向けて書かれています。いま目の前にあるredが、自分の所有していないテストのものである場合 — つまり、あなたは修正を書く立場ではなくマージゲートを運用する立場にいる場合 — このルールはそれでもあなたを拘束します。そして、そのための専用の手続きがあります:ゲートオペレーターのプレイブック。
Note
隔離(quarantine)自体が敵なのではありません。それを答えとして使うことが敵なのです。隔離は出口のあるパイプラインであって、駐車場ではありません。 次のすべてを満たすときにのみ正当です:非決定性がプロダクトではなくテストの側にあることを証明済みであること(Step 0.5)、issueがリンクされていること、どこかでallowed-to-failとして実行され続けていること、そして期限があること。さらに、隔離されている間、そのプロダクトの振る舞いは無防備であるという事実を受け入れることです。
ルール3 — テストがどうしても確率的に見えるなら、アサートしている対象が間違っている。
本質的に非決定論的なシステムは実在します(GCの停止、ネットワークのレイテンシ、スレッドスケジューリング)。その逃げ道は「ランダム性を受け入れる」ことではなく、点の値ではなく決定論的に成立する不変条件(invariant)をアサートすることです:
assert(elapsed == 43ms) // 確率的 — サンプル値をアサートしている
assert(elapsed < budget) // 決定論的 — 本当に気にしている性質をアサートしている確率的なアサーションは、ほとんどの場合、本物の不変条件ではなく都合のよい代理指標(proxy)を選んでしまったサインです。許容誤差ではなく、アサーションの方を直してください。
自分のものではない赤いCI — ゲートオペレーターのプレイブック
このページの他のすべての内容は、テストの作者に向けて書かれています:flakeをどう診断し、どう排除するか。このセクションは別の役割のためのものです。あなたはゲートオペレーター — 差分(diff)が一切触れていないテストで必須チェックが赤くなったとき、マージゲートを握っている人間または自律エージェントです。あなたはそのテストを書いていないし、このマージウィンドウの中でそれを直すこともできません。そしてルール2は、何を常態化してはいけないかをすでに教えてくれています。ルール2が与えてくれないのは、これからの30分をどう動くかの判断手続きです。それがこれです。
役割の境界こそがポイントです:作者の仕事はredに意味を持たせること。オペレーターの仕事は、redに意味があるかのように振る舞うこと — 特に、この目の前のredにはおそらく意味がないときにこそ。 オペレーターたちが私的な例外を作り始めた瞬間、ルール2の複利的な崩壊は始まっています — 個々の例外が間違っていたからではなく、そのそれぞれが記録されないまま、次のオペレーターが「すでに交渉可能になったゲート」を引き継ぐからです。
手順
何かに触れる前に、いま手にしているものを特定する。 そのredが現在のhead SHAのものであることを確認し、分類してください:インフラ障害(checkoutが死んだ、ランナーが消えた)はインフラの所有者にルーティングし、テストアイデンティティのissueは作りません。レーン障害(エントリ7の移ろうteardownシグネチャ)はレーンの所有者へ。以下に進むのはテストの失敗だけです。1つの特殊ケースは、失敗ですらなくゲートの欠陥です:正当に隔離された
@flakyテストが必須チェックの中で赤く表示されているなら、隔離とゲートの配線が矛盾しています — 隔離されたテストが必須ゲートに入っている理由はありません(必須の振る舞い ルール7)。それはCIの所有者にルーティングしてください。「無関係」という主張を、感覚ではなく証拠で検証する。 「自分の差分とは無関係」はルール1と同じ証明義務を負います。本当の無実の証明は履歴です:同じアイデンティティが、この差分が存在する前に同じシグネチャで失敗していたこと、あるいはベースブランチ上で同時期に失敗していたこと。シグネチャは一致していなければなりません — かつてはteardownでタイムアウトしていたアイデンティティが今はアサーションで失敗しているなら、それは見慣れた名前をまとった別の失敗であり、その履歴は何の無実も証明しません。同じコミット上のツインレッグは最速かつ最安の絞り込み証拠です — 等価な兄弟レッグがパスしていれば、headでの失敗が断続的(intermittent)であることを証明します — が、それ単独では差分の無実を証明できません:差分がたった今持ち込んだ断続的なレースも、まったく同じgreen/redのペアを生むからです。チェックは推移的な差分スコープの確認(共有フィクスチャ、設定、依存関係、グローバルセットアップ — 失敗したspec自身のファイルだけではなく)で完成させてください。差分の無実を証明できないなら、ここで読むのをやめてください:それはただの赤いビルドであり、差分の作者が所有すべきものです。
どんな再実行よりも先に、発生を記録する。 失敗したアイデンティティごとに1つのトラッキングissue — キーは裸のタイトルではなく、テストアイデンティティ、マトリクスのレッグも含めて — を、見つけて更新してください。発生のたびに新しく開いてはいけません。 追記する内容:ランのURL、コミット、ワークフロー/ジョブ/レッグ、attempt番号、失敗フェーズ、アーティファクトへのリンク。ログとトレースの保全が先です — 再実行は、この発生を診断可能にしていたまさにそのアーティファクトを上書きしたり失効させたりしえます。これはルール2の「記録すべきものであって、祝うものではない」の運用化であり、蓄積された履歴こそが、後になって移ろうアイデンティティ(レーンの性質)と繰り返すアイデンティティ(スペック単位の修正候補)を分けてくれます。記録なしの再実行は、ルール2が警告するrerun文化そのものです — 証拠を静かに食いつぶすリトライです。
失敗したレッグを、1回だけ再実行する。 再実行するのは失敗したジョブまたはレッグだけで、ワークフロー全体ではありません — 全体の再実行は、すでに手にしているgreenを再現するために計算資源を燃やすだけです。そして上限は、デフォルトでhead SHAごとにオペレーター起点の再実行1回です:それは「あれは一過性だったか?」に答え、マージウィンドウが再実行に求める問いはそれだけです。2回目には具体的な明示された理由が必要です — 1回目の再実行のアーティファクトが失われた、確認済みのインフラ障害がちょうど復旧した、など。「もう1回greenのチャンスを」は理由ではありません。(この上限が支配するのはオペレーター起点の再実行です。スイート自身のテストごとのリトライは、事前に予算化された別のメカニズムであり — リトライ予算を参照 — そのpass-on-retryテレメトリは同じトラッキングissueに載ります。)それ以上の再実行は失敗頻度の推定にはなりますが、それは診断 — テストの所有者が自分の時間でやるburn-in作業 — であって、他の全員の作業を堰き止めているマージゲートの仕事ではありません。
上限に達したら、PRをブロックされたままにしてエスカレートする。 再実行の開始をやめてください。ステップ1の分類に従ってルーティングし — 繰り返すアイデンティティはテストの所有者へ、移ろうアイデンティティはレーン/CIの所有者へ — トラッキングissueを引き渡します:証拠、再実行の回数、アイデンティティの履歴。redを越えたマージは、どんな証拠レベルでもオペレーターのメニューには載っていません — 下記を参照。隔離も同様に、オペレーターのブロック解除手段ではありません:それは所有者の決定であり、プロダクトかテストかの証明義務の下流にあるものです。
なぜmerge-on-redは決してオペレーターの判断ではないのか
理由は2つ — 1つは認識論的、もう1つは構造的です。
認識論的な理由:最良の場合でも、あなたの証拠が無実を証明するのは差分であり、ゲートが守っているものは差分だけではありません。失敗したアイデンティティは偽物かもしれません — 30%の確率で失敗する本物のプロダクトバグです。「この差分とは無関係」は、「あなたがその上に出荷しようとしているプロダクトに生きた欠陥がある」と完全に両立します。差分の無実の証明は、そのredが誰の問題かに答えるだけで、そのredが問題であるかどうかには答えません。
構造的な理由は、ルール2を自分自身に適用したものです:私的な判断で — その判断がどれほど優れていても — redを越えてマージしたオペレーターは、「十分に自信のあるオペレーターがいればredは交渉可能である」ことを実証してしまい、未来のすべてのオペレーター(そしてその前例で訓練されるすべてのエージェント)がそれを引き継ぎます。ゲートの価値はまさに、文脈の中の自信には屈しないという点にあります。
つまりこの線引きは、判断の問題ではなく権限の境界です:ゲートオペレーターは赤い必須チェックをバイパスできません — PRはブロックされたままにし、証拠を上に上げます。リポジトリにbreak-glass(緊急突破)の経路があるとしても、それは文書化されたポリシーであり、指名された権限者が、正確なSHA、証拠へのリンク、明示的なリスク受容、補完的な検証、そしてフォローアップissueのすべてを記録に残して発動するものです — 自信に満ちた根拠を携えたオペレーターの即興ではありません。
Note
自律エージェントにとって、これはハードルールです。 マージゲートを運用するエージェントは、インシデントの最中に自分自身の権限を拡大しません。記録し、上限の中で再実行し、そしてPRをブロックされたままにしてすべてを表に出してください。「無関係だと証明したので、そのままマージした」は、証明が正しかったとしても間違った結末です — その証明はエスカレーションの中に置かれるべきものであって、マージの根拠の中に置かれるべきものではありません。
Part 1 — 根本原因カタログ
すべてのE2Eフレイクには原因があります。以下の7つは実際のプロジェクトで圧倒的多数のケースをカバーし、それぞれに決定論的な修正があります。
Note
この7つはブラウザ固有のものであり、ネイティブのスイートは別の理由でflakyになります。 以下のカタログ(waitForTimeout、networkidle、アニメーション、ハイドレーションレース)は、E2E/ブラウザの形をしたflakyさです。ネイティブのスイート — Rust/cargoプロジェクト、Goサービス、ブラウザを使わないあらゆるテストランナー — は、それと並行する一連の原因でflakyになります:
非決定論的なスケジューリング — スレッドやタスクのインターリーブに依存するテスト(ハイドレーションレースのネイティブ版)。
固定の
sleep/タイムアウト期限 — 非同期処理を待つためのハードコードされたsleep(N)は、裸のwaitForTimeout(N)と同じ推測です。代わりに実際の条件をポーリングするか、完了シグナルを待機してください。ポートの競合(
EADDRINUSE) — 2つの並列テストが同じ固定ポートにバインドするケース。ポート0(OS割り当て)にバインドするか、実ポートが必要なテストを直列化してください。アプリケーションポートがすべて別々であっても、プロセスの補助ソケット(インスペクター、HMR、メトリクス)に同じ失敗モードが隠れていることがあります — 隠れた補助ソケットのポートレースを参照してください。共有されたグローバル/プロセス状態 — あるテストが変更し別のテストが読むstatic、シングルトン、環境変数。テスト順序の結合のネイティブ版です。テストごとの状態を分離してください。
パッケージマネージャの環境変数リーク(
INIT_CWD、npm_config_*) — ネイティブ固有ではありません。パッケージマネージャのスクリプト経由で起動されるネストしたビルド/テストツールはすべて、これらの環境変数を継承します。パッケージマネージャで実行されるスクリプトの下では、pnpmがINIT_CWD=<起動元のルート>をエクスポートします。ネストしたfixtureビルドのツールが、相対パス(例:--content-dir)を自身のcwdではなくINIT_CWDに対して解決してしまうと、間違ったディレクトリを静かにスキャンしてしまいます — その結果、起動元のルートがローカルのシェルとは異なるCI環境でのみ再現する、決定論的に空のデータが生成されます。修正方法:ネストしたビルドについては、INIT_CWDやnpm_config_*を信頼するのではなく、作業ディレクトリを明示的に固定してください。ファイルシステムの順序 — ディレクトリ列挙の順序や共有された一時パスへの依存。テストごとに一意の一時ディレクトリを使い、
read_dirの順序を決して仮定しないでください。2つの独立したデッドライン —
spawnSync/execSyncで子プロセス側に寛容なタイムアウト(timeout:、例:30秒)を設定していても、テストランナー側のデフォルトのテストごとのタイムアウト(vitestでは5000ms)に依存していると、ホストのCPU負荷が高いときにflakyになります。より厳しい方の期限が勝ち、失敗メッセージ("Test timed out in 5000ms")はハングのように見えますが、実際にはサブプロセスが負荷下で単に遅かっただけで、自身の予算内であれば成功していたはずです。再起動直後のロードアベレージの急増(10コアマシンでロード59)により、ローカルゲートが4回連続で失敗し、それぞれ異なるテストファイルで発生しました。ネットワークに一切触れないtrivialな--helpテストも含まれていました。修正方法:デッドラインをテストごとではなく設定レベルで揃えること — projectsの分割によって、サブプロセスを多用するプロジェクトにだけ引き上げたtestTimeoutをスコープしてください。プロジェクトごとのタイムアウト予算を参照してください。ランナーのkill期限を合算で超える逐次ステージ予算 — 同じ罠が1つ上のレイヤーで起きるケースです:1つの子プロセス期限がランナーと競うのではなく、個々には妥当なステージ期限の連鎖の_合計_が、ランナーのテストごとの予算を超過します。逐次ステージを持つdevサーバーe2e — 3×120秒のブート期限、3×60秒のシナリオ期限、加えてセットアップ/ティアダウン — は、600秒のテストごとkill期限に対して最悪ケース合計が約630秒になります(nextestの
e2e-heavyグループでは、この期限は単一の期間フィールドではなくslow-timeoutのperiod × terminate-afterで決まります —terminate-afterはperiodの整数倍を表す回数です)。結果は2つ、どちらも厄介です:全ステージが遅いが「それでもパスする」病的なランでは、ランナーは_パスしていたはずの_テストを終了させます。そしてこのkillはテストの外側から発火するため、ランナーの終了はテスト自身の診断パニックとログのフラッシュを先取り(preempt)しえます — その結果、テストがパニックメッセージに丁寧に添付したセッションログが、いちばんそれを必要とするランに限って失われます。(nextestのタイムアウト終了は_グレースフル_です — まずシグナルを送り、設定されたグレースピリオド(デフォルト10秒)を過ぎて初めて強制killします — つまりこれは遅いパニックのフラッシュの先取りであって、必ずしも即座のSIGKILLではありません。)ここではすべての待機がイベントキーされ、各ステージ期限も個別には妥当だったため、上記のどのルールも発火しません — ステージをまたいだ予算の算術だけがこれを捕まえます。これは、CIのtimeout-minuteskillがHTMLレポーターのonEndフックを先取りするという、このドキュメントの観察と同じ形です。ヘッドルームのヒューリスティック(厳密な法則ではなく経験則):テスト内の_すべての_逐次期限 — セットアップとティアダウンを含む — の最悪ケースを合算し、その合計をランナーの実効的なテストごとkill期限の約75%未満に保ってください。そうすればテスト自身の診断的失敗が、ランナーのkillより必ず先に発火します。安価なレビューの兆候:1つの大きな共有デッドライン定数(BOOT_DEADLINEの類)を複数の逐次待機で使い回しているテストは、この算術チェックに値します。プラットフォームによって変わる入力の形(shape) — このリストの中で唯一の異物であり、テストがまったく問題ではない唯一の原因です。下記を参照してください。
ネイティブのflakeを機械的に隔離する方法(#[ignore] + cargo test -- --ignored)は、隔離パイプラインのRust/cargoノートを参照してください。
CIで最初に現れる失敗のうち、そもそもこのカタログに属さない形がひとつあります:テスト自身のフィクスチャペイロードが文字列不在アサーションを引っかけるケースは完全に決定論的で、単にCIで最初に表面化してプロダクトのリグレッションのように見えるだけです。そのような失敗をflakeや実バグとして扱う前に、不在アサーション vs 自分自身のフィクスチャデータを参照してください。
偽物: flakeが実はプロダクトのバグである場合
上記の原因はすべて、テスト自身の機構における非決定性です — スケジューリング、sleep、ポート、共有状態。しかし、もう1つの形があり、それはカテゴリとして根本的に異なります:
プラットフォームが、プロダクトに渡す「入力の形(shape)」を非決定的に変え、プロダクトがその形のいずれかを正しく扱えていない。
このときテストは、その入力を所与とすれば完全に決定論的です。flakyではありません。本物のバグをサンプリングしているために、断続的に「正しく失敗している」のです — そのバグは、そのプラットフォームの全ユーザーの本番環境で生きており、テスト側をいくらデフレイクしても直りません。
これが重要なのは、この場合、隔離パイプラインが積極的に害をなすからです。隔離はプロダクトのカバレッジを停止しますが、壊れているのはまさにプロダクトです。テストを #[ignore] にすることは、証明済みの欠陥を、責任を果たしたように見える証跡の裏に葬ることになります。
隔離のStep 0ではこれを捕まえられません。 「そのテストは一度でも本当にパスしたか?」は自明にパスします — そのテストは実際に、しかもほとんどの場合パスするからです。それこそがこの失敗モードの本質です。
Warning
診断 — 何かを隔離する前にこれをやってください。数分で終わります。 失敗した実行でプロダクトの入力をログに出し、パスした実行のものと差分を取ってください。
入力が同一で、タイミングだけが違う → 本物のflakeです。上記のカタログに沿って作業してください。
入力が異なる → プロダクトのバグです。止まってください。プロダクトを直してください。
具体例:あるRust製静的サイトビルダーのdevサーバーのテストは、1つのコンテンツファイルを編集したとき、そのファイル自身のルートだけが再レンダリングされ、兄弟ルートは再レンダリングされないことをアサートしていました。macOSでは4回中2〜3回失敗し、Linux CIでは一度も失敗しませんでした。起票されたissueは — 隔離パイプラインに正しく従った結果 — ステップ1として #[ignore = "flaky: <url>"] を推奨していました。
プロダクトに既存の tick 計装を有効にしたところ、1回の実行で答えが出ました:
# 失敗した実行
tick(): kinds=[alpha.mdx:Created] fan_out_safe=false # -> narrowing OFF、全体を再レンダリング
# パスした実行
tick(): kinds=[alpha.mdx:Modified] fan_out_safe=true # -> narrowing ON、1ルートのみ同じテスト、同じコード、OSが届けた入力だけが違う:macOSのFSEventsは、既存ファイルのin-place編集を Created イベントに合体(coalesce)させることがあり、これがプロダクトの「全部 Modified」ゲートを通らず、macOSの全ユーザーに対して、すべてのコンテンツ編集で最適化を静かに無効化していました。テストは正しかったのです。隔離していたら、テストがちょうど捕まえたバグを隠すことになっていました。
メカニズムが違っても、見分け方は一般的です:負荷や順序ではなく「プラットフォーム」と相関する失敗は、タイミングの臭いではなくプロダクトのバグの臭いです。 特定のOSでだけ、特定のファイルシステムでだけ、特定のwatcherバックエンドでだけ、特定のCPUアーキテクチャでだけ失敗するテストは、「プロダクトがその環境を異なる形で扱っている」と告げています。黙らせる前に、その主張を確認する価値があります。
1. タイミング待機(裸の waitForTimeout)
裸の waitForTimeout(N) は推測です。「Nミリ秒あれば十分だろう」というだけです。遅いCIランナーでは間違いとなり、アプリを高速化したデプロイ後には間違いになり、低速化したデプロイ後にも間違いになります。修正方法は、アプリが本当に必要な状態に達した瞬間に解決するウェブファーストのアサーションまたはイベントキーの待機です — 任意の遅延の後ではありません。
// Anti-pattern
await page.waitForTimeout(2000);
await expect(page.locator(".result")).toBeVisible();
// Fix: resolve on the real condition
await expect(page.locator(".result")).toBeVisible({ timeout: 10_000 });アプリイベントが適切なシグナルとなるケースについては、Part 2 のデフレイキングレシピを参照してください。
2. リクエストを発火しないクライアントサイドナビゲーションでの networkidle
waitForLoadState("networkidle") は、500ms間ペンディング中のネットワークリクエストがないときに解決します。クライアントサイドSPAナビゲーション(JavaScriptで処理されるルーティング、新しいネットワークリクエストなし)では、この条件はナビゲーションが始まった直後に解決することがあります — 新しいビューがレンダリングされた後ではなく。ナビゲーションはリクエストを発火しないため、networkidle は決してブロックしません。
修正方法は、待機を実際の完了シグナル(URLの変更、安定したDOM要素、またはアプリレベルのイベント)にキーすることです。
// Anti-pattern: resolves before the view is ready on SPA navigations
await page.waitForLoadState("networkidle");
// Fix: wait for the real completion signal
await page.waitForURL("/dashboard");
await expect(page.locator("h1")).toBeVisible();3. 進行中のアニメーション・トランジション
CSSトランジションやアニメーションの進行中に要素の計算スタイルや位置をアサートすると、非決定論的な値が生じます — 要素はフライト中です。2つの修正方法があります:
テスト環境でアニメーションを無効化する —
page.emulateMedia({ reducedMotion: "reduce" })またはCSSオーバーライドを使用。落ち着いた状態をアサートする — トランジション中にテストするのではなく、トランジション終了後の安定した値を待機(例:安定したトランジション後の値で
toHaveCSSを使用)。
// At fixture setup — forces prefers-reduced-motion on all tests
await page.emulateMedia({ reducedMotion: "reduce" });4. 共有状態・テスト順序の結合
前のテストが残した状態(データベースの行、クッキー、localStorageのキー、グローバル変数)に依存するテストは、スイートが順番通りに実行されるときはパスし、そうでないときは失敗します。テストの実行順序は保証されません。
修正方法は、テストごとの状態を分離することです:各テストに必要なものを独自の beforeEach / test.beforeEach でセットアップし、終了後にティアダウンし、別のテストが先に実行されることに依存しないようにします。
test.beforeEach(async ({ page }) => {
// Reset to a known clean state before every test
await page.evaluate(() => localStorage.clear());
await page.goto("/");
});5. ハイドレーションレース
JavaScriptアイランドがハイドレートされる前に、そのアイランドが制御するインタラクティブな要素の機能をアサートすると、レース条件が発生します。アサーションはビジュアル的にはパス(DOM要素は存在する)しても、動作はまだ配線されていません。ハンドラーがアタッチされる前にクリックが発火されるのです。
修正方法は、スリープではなくインタラクティブ性のシグナルを待機することです:
// Anti-pattern: element is visible but not yet interactive
await expect(page.locator(".submit-btn")).toBeVisible();
await page.locator(".submit-btn").click();
// Fix: wait for the app to signal readiness
await page.waitForFunction(() => document.querySelector(".submit-btn")?.dataset.hydrated === "true");
await page.locator(".submit-btn").click();6. 無関係な待機を飢えさせるアプリ内バックグラウンド処理
上の5つの原因は、テスト自身の仕組みの中にある非決定性でした。この原因はアプリ側の仕組みに住んでいます:アプリがブート時に開始するバックグラウンド処理が、テストが待っているインタラクティブな処理とリソースを奪い合う — そして開発サーバーの下では、バックグラウンド側が勝ってしまうことがあるのです。
実例:あるビューワーアプリが、遅延ロードされるエディターのチャンクをブート時にアイドルプリフェッチしていました。Vite開発サーバー(Playwrightの webServer が起動するのもこれです)の下では、ワークスペースの development エクスポート条件がそのパッケージを生のTSソースとして配信するため、「1つのチャンク」が約3,500個の個別モジュールリクエストに膨れ上がり、ページロードのたびに数秒間、ブラウザのホストごとコネクションプール(観測されたHTTP/1.1開発サーバーのケースでは6本 — そのセットアップの性質であって、ブラウザの普遍的な定数ではありません)を独占しました。その結果、無関係な遅延インポート — エディターを一切開かないテストの設定ダイアログ — のマウントが約380msではなく2〜9.4秒かかるようになり、正しくイベントキーされた5秒の待機を一貫して超過しました。本番ビルドでは同じプリフェッチはバンドル済みチャンクへの数リクエストにすぎません。この飢餓はdev/e2e限定の増幅です。
このフレイクの「兆候」は、どれも通常のflakeらしく見えません:
どこにもエラーがない。 pageerrorもconsoleエラーも失敗リクエストもなく、待機がただタイムアウトする。
待っていた要素は最終的に現れる。 タイムアウトを越えてポーリングすると、「二度と現れない」のではなく数秒遅れでマウントされる。
失敗するテストが、テスト対象の変更と無関係。 変更されたコードを実行するからではなく、ストームとページロードを共有するために失敗する。
失敗が相関するのはテストの動作ではなく、アプリがブート時に行うこと。
ネットワークウォーターフォールに、CPUが暇なまま同一オリジンのリクエストがキューで停滞している様子が映る。 これがコネクション飢餓をCPU飢餓から切り分ける兆候です — 下のレイテンシA/Bはバックグラウンド処理が遅延の原因であることを証明しますが、律速している制約がコンピュートではなくコネクションであると特定するのは、この「アイドルなのに停滞」のウォーターフォールです。
証明は機械的です(ルール1 — flakyと呼ぶ前に証明する)。まず、要素が実際にいつ現れるかを測定します:
const t0 = Date.now();
await settingsMenuItem.click();
let appearedAt: number | null = null;
for (let i = 0; i < 200; i++) {
if (await page.locator(".settings-dialog-overlay").count()) {
appearedAt = Date.now() - t0;
break;
}
await page.waitForTimeout(100);
}
console.log(`overlay appeared after: ${appearedAt ?? "NEVER"}ms`);次に、疑わしいバックグラウンド処理をA/Bテストします — アプリのブート前にwindowフラグを立て、アプリのeffect側に一時的なガードで反映させます(測定後は必ず戻すこと):
// In the debug spec, before page.goto():
await page.addInitScript(() => {
(window as any).__AB_DISABLE_PREFETCH = true;
});// TEMP A/B guard in the app's prefetch effect — revert after measuring
useEffect(() => {
if ((window as any).__AB_DISABLE_PREFETCH) return;
const id = requestIdleCallback(() => prefetchHeavyChunk());
return () => cancelIdleCallback(id);
}, []);実例の数値:プリフェッチありでマウントまで 2046 / 9384 / 8861 / 7459 ms、なしで 383 / 363 / 385 / 365 ms。これは相関ではなく因果です。
修正の優先順位:どのタイムアウトにも触れる前に、バックグラウンド処理をソースでゲートする。 この実例ではプリフェッチをprod限定にしました(if (import.meta.env.DEV) return;) — 本番はコストが安い場所で最適化を維持し、dev/e2eは必要とするフローの内側でオンデマンドにグラフをロードします。flakyな待機のタイムアウトを先に引き上げるのはアンチパターンです:1つの待機の飢餓を糊塗するだけで、スイート内の他のすべての遅延インポートはマージナルなままだからです。
Warning
これはCPU飢餓に見えますが、違います。 負荷下でタイムアウトするspecは通常「ランナーにもっとコアを」という対処(ランナーサイジングのルール3)に流れます。コネクション飢餓は症状こそ同一ですが、律速している制約が違います — ブラウザのホストごとコネクションプールはvCPUを増やしても増えません。ランナーのサイズ変更では直らず、修正はアプリ/配信レイヤーにあります。
続編の罠 — コストは移動するだけで、消えはしない
バックグラウンド処理をゲートしても、そのコストが消えるわけではありません。オンデマンドのロードをカバーすることになった別の待機へと、コストが移動するのです。実例では、prod限定ゲートは飢えていた設定ダイアログのテストを直すと同時に、約3,500モジュールのダウンロードをエディターの初回マウント待機の内側へ押し込みました — エディターを開くすべてのテストで+10〜28秒 — その結果、ローカルでは証明済みの修正がCIでは15分のシャード上限に阻まれて赤いままでした。
この移動を捕まえるテクニックが、2つのCIラン間のテストごとの実行時間diffです。Playwrightのlistレポーターは、完了したテストごとに実行時間つきの行を1行出力します。グリーンだったランのログと壊れたランのログからそれらを抽出し、spec.ts:line:col をキーにして、リトライを除外し、共通するテストを比較します:
# One run's log → "<file:line:col>\t<duration>" pairs.
# Drop retry lines BEFORE the sed — it rewrites each line to key+duration,
# which destroys the "(retry #N)" marker a later grep would need.
grep -E " ✓ " run.log |
grep -v "retry #" |
sed -E 's/.*› ([^ ]+\.spec\.ts:[0-9]+:[0-9]+) › .*\(([0-9.]+m?s?)\)$/\1\t\2/' > run.times
# Repeat for the other run, then compare the common keys.実例では、両方のランに共通する50テストの完了テスト秒の合計が 710秒 → 1054秒 になっていました — 個々のテスト単体では見えない(それぞれリトライでパスしてしまう)回帰が、集計では見間違えようがありません。数値を意味のあるものに保つための、3つの誠実さの注意点:
listレポーターはCIで明示的に設定されていなければならない。 PlaywrightのCIデフォルトは
dotで、テストごとの行を一切出力しません — このサルベージが存在するには、reporter: [["list"], ["html"]](相当)があらかじめconfigに入っている必要があります。file:lineキーはドリフトし、裸のタイトルは融合する。 行番号が安定なのは近接コミット間だけです。2つのラン間でspecファイルの行がずれている場合は、行番号を落としてプロジェクト+ファイル+タイトルをキーにしてください——タイトル単独ではだめです。クロスラン・トリアージのキーはテストタイトルではなくテストアイデンティティにするを参照。マッチしたテスト数/しなかったテスト数も報告してください。合計は完了テスト秒であって、実時間ではない。 並列ワーカーは重なって走り、タイムアウトで途中killされたテストは行を出力しません — 壊れたランの合計は真のコストを過小評価しています。
これはインシデントのサルベージであって、テレメトリではありません。テストごとの実行時間を日常的に取りたいなら、onTestEnd から構造化レコードを出力する10行のカスタムレポーターの方がログのパースに勝ります — grepは、そのレポーターがまだ存在しなかったランのために取っておいてください。
Note
なぜここでジョブログが唯一のアーティファクトなのか: CIの timeout-minutes killはPlaywrightのHTMLレポーターが onEnd フックに到達する前にランナーを終了させるため、いちばんトリアージが必要なランに限ってレポートアーティファクトがアップロードされません。ジョブログにストリームされたlistレポーターの行は生き残ります。それをパースしてください。
同じdiffをローカルで2つのJSONレポートから組み立て直す場合は? 両方のレポートをPlaywrightの outputDir の外に書かなければなりません——ラン開始時のワイプがラン2の開始前にラン1のレポートを削除し、比較は構造的に不可能になります。残したいアーティファクトは outputDir の外に置かなければならないを参照してください。
移動したコストに対する最終形の修正 — そもそもテストごとに数千のソースモジュールを配信するのをやめる — は、Playwrightパターンのバンドル依存E2Eレーンです。
7. レーンのオーバーサブスクリプション
エントリ6は、アプリの機構がテストの機構を飢えさせるものでした。これはそのどちらでもありません:レーンそのものが過剰予約(オーバーサブスクライブ)されていて、予算が尽きた瞬間にたまたまリソースを掴んでいたスペックに失敗が着地するのです。そのスペックの何かが原因なのではありません——ただ悪い場所に立っていただけです。
シグネチャは一目で見分けがつくほど特徴的です:
Tearing down "context" exceeded the test timeout of 30000ms.数値ではなくフェーズを読んでください。これは、テスト本体が終わった後のフィクスチャteardownで発火します——失敗したアサーションも、失敗したロケーターも、失敗した待機もありません。スペック自身の仕事はすでに完了していて、そのブラウザコンテキストを閉じる処理が予算を超過したのです。スペックごとの説明では、スペックに依存しないフェーズを説明できません。
出会う順に、見分けるための兆候を挙げます:
ローカルのフルスイートランの終盤に現れます。 レーンの合計負荷がピークに達したときであって、そのスペックが早い時間帯に実行されるときではありません。
ランのたびに違うスペックに当たります。 これが決定的な兆候です。以下はすべて裏付けにすぎません。
影響を受けたどのスペックも、単体で再実行するとgreenです。 スペックは正常です。ずっと正常だったのです。
アサーションは1つも失敗していません。 本物の待機が本当にタイムアウトするエントリ6と比較してください。
ローカルの
retries: 0では再現し、CIでは決して再現しません。 CIではretries: 2がそれを吸収するからです——リトライ回数の非対称性という罠を参照。
Warning
レースに負けただけのスペックを隔離してはいけません。 それは main 上の既存テストであり、リトライ回数ゼロのゲートで本当に赤くなったので、@flakyの「新規テストにタグを付けるな」ガードはクリアします——それでもタグ付けは間違いです。欠陥のないスペックのカバレッジを停止し、レーンの欠陥はそっくり温存され、次のランには新しい犠牲者があてがわれます。それを3ラン続ければ、健全なスペックを3つ隔離して何も直していないことになります。
ルール:移ろうアイデンティティはレーンの性質。繰り返すアイデンティティだけが、スペック単位の修正候補です。 ルール1の証明義務はそのまま適用されます——いま手にしているのがどちらなのか分かるまでは、何も診断できていません。
診断は、並列数を下げた1回のランです。 レーンのオーバーサブスクリプションは負荷の関数なので、負荷を取り除けば失敗も取り除かれます——わずかに、ではなく、きれいに:
# If the full suite fails on a roaming spec but --workers=1 is green,
# the binding constraint is the lane, not any spec it landed on.
pnpm test:e2e --workers=1目視ではなく、ラン横断で確認してください。2つのランのJSONレポートを失敗テストでdiffすれば、問題になる唯一の問い——同じテストが2回失敗したか?——に答えが出ます。
Warning
diffのキーはテストアイデンティティ——file:line +タイトル——であって、裸のタイトルでは決してありません。 2つのスペックがタイトルを共有することはありえます(ダイアログのspecとメニューのspecの「closes with Escape」)。タイトルをキーにしたdiffはそれらを幻の常習犯へ融合させます。それはこのエントリの判断を反転させます:一度も繰り返していないスペックを隔離することになるのです。クロスラン・トリアージのキーはテストタイトルではなくテストアイデンティティにするを参照。
レポートはPlaywrightの outputDir の外に書かれなければなりません:outputDir は毎回のラン開始時に空にされるため、ラン2がラン1のレポートを削除し、比較は構造的に不可能になります。PLAYWRIGHT_JSON_OUTPUT_NAME は test-results/ ではなく兄弟ディレクトリ(b4push-reports/)へ向けてください——残したいアーティファクトは outputDir の外に置かなければならないを参照。
確認できたら、レバーはレーン自身の予算です——まずワーカー数、次にトレース保持とテストごとのタイムアウト。これは重いローカルゲートでのvitest並列数の上限のPlaywright版であり、理由も同じです:重いローカルゲートは実時間で信頼性を買います。健全なスペックで赤くなるゲートは、2分余計にかかるゲートより価値が低いのです。
Note
3つの症状、3つの律速制約——そしてテストの問題なのは、そのうち1つだけ。
| 症状 | 律速制約 | レバー |
|---|---|---|
| 2 vCPUのCIランナーでspecがタイムアウトする | コア数 | 2→4 vCPU(ランナーサイジングのルール3) |
| 本物の待機がタイムアウトし、CPUがアイドルのまま同一オリジンのリクエストが滞留する | ホストごとのコネクションプール | アプリ/配信レイヤー(エントリ6) |
| ローカルのフル負荷時に、移ろうスペックでteardownがタイムアウトする | レーン自身の予算 | ワーカー数の上限——@flaky タグではない |
ローカルの行にはリサイズできるランナーがありません:自分のマシンでは、ワーカー数こそがvCPUのダイヤルです。「ゲートは赤いがコアは買えない」からと @flaky に手を伸ばすのは、レーンの欠陥をテストの欠陥へロンダリングするやり方です。
実例:あるTauriアプリのローカル b4push レーンが、数ラン連続でcontext-teardownタイムアウトで失敗しました——ランのたびに違うスペックで、レビュー対象の変更に触れてすらいないスペックも含まれ、どれも単体再実行ではgreenでした。隔離パイプラインへ忠実に従って到達した修正案は、直近の犠牲者への @flaky タグ付けでした。実際の原因はレーンでした:デフォルトのワーカー数+開発サーバー+トレース保持+ retries: 0 が、1台の高負荷なラップトップに同居していたのです。--workers=1 はgreenでした。どのスペックについても、間違っていたものは何一つありませんでした。
移ろう vs 繰り返すのルール自体は、ホステッドCIにもそのまま持ち込めます:ホステッドなマトリクスの連続するランが毎回違うアイデンティティで失敗するのは、CIの衣装をまとった同じレーンシグネチャです。しかしレバーは1:1では対応しません — ワーカー数の上限とトレース保持はCIランナーにも存在しますが、もはやダイヤルの全体ではありません。ホステッドCIには、ラップトップにはないレバー — ランナーの形状(shape)とマトリクストポロジーそのもの — が加わるからです。何かをリサイズする前に、そのレッグの律速制約を診断してください(ランナーサイジングのルール3)。そして問題のCIランたちがコミットを共有しているなら、あなたはツインレッグ比較も手にしています — 追加のランに支払う前に、それを使ってください。
クロスラン・トリアージのキーはテストタイトルではなくテストアイデンティティにする
このページのすべてのラン横断比較——上の実行時間diff、レーンのオーバーサブスクリプション、スケジュール再試験のリトライパステレメトリ、あらゆる「これは2回失敗したか?」トリアージ——は、あるキーで2つのランを結合します。そのキーはテストのアイデンティティ:プロジェクト+ファイル+タイトルでなければなりません。 裸のテストタイトルはアイデンティティではなく、それを使うと比較は静かに壊れます。
2つのspecファイルが同じ test() 名を含むことを妨げるものは何もありません:
// e2e/settings-dialog.spec.ts
test("closes with Escape", async ({ page }) => { /* ... */ });
// e2e/command-palette.spec.ts
test("closes with Escape", async ({ page }) => { /* ... */ });ラン1では設定ダイアログの方が失敗し、ラン2ではパレットの方が失敗します。タイトルをキーにしたトリアージは closes with Escape が両方のランで失敗したと見なし、常習犯を報告します——存在しないスペックです。独立した2つの単発の失敗が、1つの幻へ融合したのです。
Warning
幻の常習犯は、修正か隔離かの判断を反転させます。 「ラン間で繰り返す」ことこそが、本物のスペック単位のフレイク(修正するか、パイプラインを通して隔離する)と、レーンレベルのリソース競合シグネチャ(レーンを直す——ワーカー数を絞り、トレース保持を削る——そして何も隔離しない)とを分けるルールです。タイトル融合は、何も繰り返していない2つの失敗から「繰り返している」証拠を捏造します。結果は選びうる中で最悪です:無実のスペックが隔離に入ってその振る舞いは無防備になり、両方の失敗を実際に生んだレーンの問題は生き続けて、新しいスペックを叩き続けます。
キーはすでにアーティファクトの中にあります——ほとんどのトリアージスクリプトが、ただ捨てているだけです。PlaywrightのJSONレポーターは file、line、title を同じspecノードに載せているので、正しくキーする追加コストはjqのフィールド1つです:
# WRONG -- bare title. Two specs named "closes with Escape" fuse into one key.
jq -r '.. | objects | select(.tests? and .title?) | .title'
# RIGHT -- project + file + line + title is a real identity.
jq -r '
.. | objects
| select(.tests? and .title?)
| . as $spec
| .tests[]
| "\(.projectName // "default")\t\($spec.file):\($spec.line)\t\($spec.title)"
'キーが壊れうる2つの経路それぞれについて、キーを誠実に保つルールが1つずつあります:
落とすのは行番号。ファイルは決して落とさない。 コミット間でspecファイルがずれたときにドリフトするのは行番号で、曖昧さを解消してくれるのはファイルです。
プロジェクト+ファイル+行+タイトルからプロジェクト+ファイル+タイトルへの降格は正しいままです——タイトルへの降格はそうではありません。行番号を落とすときの注意が1つ:$spec.titleは_末端の_タイトルであり、同じファイル内の2つのdescribeブロックが同じ末端タイトルを宣言しえます——スイートがネストしているなら、末端タイトル単独ではなくタイトルパス全体(スイートの連なり › 末端)をキーにしてください。ファイルを復元できないときは、推測せずに曖昧さを報告する。 ログ行やレガシーなアーティファクトがタイトルしか持っていないなら、ツリー内でそのタイトルを宣言しているspecの数を数えてください。2つ以上あれば、その行は繰り返し検出には使えません——投票させるのではなく、トリアージ出力でそう言ってください。
Note
同じキーがissueの起票も支配します。 flaky: closes with Escape と題されたトラッキングissueは、無関係な2つのスペックからテレメトリを集める1つのissueであり、どちらのスペックの修正でもきれいに閉じられません。issueのタイトルにはアイデンティティを入れてください——flaky:——そうすればdedupクエリは1つのスペックに、そのスペックだけにマッチします(同じspecが複数のPlaywrightプロジェクトで実行されるなら、プロジェクトもタイトルに入れてください)。これはデデュープされた試験issueのワークフロー名をタイトルに入れるルールと同じ規律です:dedupキーは、それが識別する対象と同じだけ具体的でなければなりません。
ツインレッグ比較:同一コミット、同一スイート、赤いのは片方のレッグだけ
ここまでのすべての比較は、時間をまたいでランを結合します — 今回のランと以前のラン、ドリフトする実行時間、繰り返すアイデンティティ。しかし、パイプラインが同じスイートを同じコミット上で2回以上実行しているときにはいつでも、より鋭い比較が利用できます:必須チェックとその同一な兄弟マトリクスレッグ、再トリガーされたワークフロー、同じワークフローの2つのラン。片方のレッグはすべてパスし、もう片方は1つのテストで失敗する。同じツリー、同じスイート、同じ瞬間 — そして追加のランのコストはゼロです。パイプラインがすでに両方の代金を払っているからです。
このペアが語りうるすべてを、1つの前提条件がゲートしています:2つのレッグは等価でなければなりません — 同じOS、同じランタイム、同じブラウザ、同じ設定で、違うのはどのランナーインスタンスが実行したかだけ。非等価なレッグ間(UbuntuのレッグとmacOSのレッグ、Node 20とNode 22)でのgreen/redの分裂が証明するのは、その失敗がレッグ固有であることだけです — これは別種の、しかし同じくらい価値のあるシグナルです:決定論的で環境固有の失敗、もしかするとまさにこの差分がそのプラットフォームに持ち込んだリグレッションかもしれません。プラットフォームと相関する失敗は断続性ではなく偽物の兆候であり、無実のメモではなく再現ランに値します。
等価なレッグを前提とすれば、このペアが証明することは即座で安価です:この失敗は、このコミット上で断続的(intermittent)である。 この正確なツリーの、この正確な条件下での一方の実行がgreenを、もう一方がredを生んだのですから、「差分がこのテストを決定論的に壊した」という仮説は到着した瞬間に死んでいます — 再現ランも、bisectも不要です。インシデントの最中のゲートオペレーターにとって、これは可能なかぎり最速の問いの絞り込みです。
2つの限界が、この比較を誠実に保ちます。第一に、ツインレッグが分類するのは断続性であって、因果ではありません。 このペアは差分の無実を証明しません:差分がたった今持ち込んだ断続的なレースも、まったく同じgreen/redの分裂を生みます。そして等価なレッグであっても実験室のコントロール群ではありません — キャッシュの状態、外部サービス、タイミングは、依然としてレッグ間で異なります。無実の証明にはその上に履歴が必要です:同じアイデンティティが差分の存在する前に同じ形で失敗していたこと、あるいはベースブランチ上で同時期に失敗していたこと。加えて推移的な差分スコープの確認(共有フィクスチャ、設定、依存関係、グローバルセットアップ)。第二に、このペアは失敗したアイデンティティのラン横断での振る舞いについて何も語りません — それが移ろう(レーンの性質 — エントリ7、そのホステッドCI版を含む)のか、繰り返す(スペック単位の修正候補、あるいは偽物)のかは、依然として上記のアイデンティティキーのクロスラン履歴を必要とします。
要するに:ツインレッグが答えるのは「これはhead上で決定論的か?」であって、「マージは安全か?」ではありません。アイデンティティのトラッキングissueに記録し、無実の証明を担う履歴と並べて、そこで投票させてください。
Part 2 — デフレイキングレシピ
これら4つのステップを順番に適用します。各ステップは機械的です — 判断力は不要です。以下のステップ5は、4つのどれを使っても待機対象が何もない場合のエスカレーションです。
ステップ1 — タイミング待機をイベントキーの待機に置き換える(リスナーはトリガーの前にインストール)
アプリレベルのイベント(フレームワークのライフサイクルフック、カスタムDOMイベント、window 上のフラグ)でシグナルされるナビゲーションやトランジションの場合、信頼できる唯一のパターンは次の通りです:
リスナーをインストールする。
アクションをトリガーする。
シグナルを待機する。
この順序は重要です。 アクションが発火した_後に_インストールされたリスナーは、イベントを完全に見逃す可能性があります — リスナーがアタッチされる前にイベントがすでに発火しているためです。常にリスナーを先にインストールしてください。
// Install the listener BEFORE the action
await page.evaluate(() => {
window.__navDone = false;
addEventListener("framework:after-swap", () => {
window.__navDone = true;
});
});
// Then trigger the navigation
await page.click("a[href='/about']");
// Then await the signal — using Playwright's own timeout, never an in-page setTimeout
await page.waitForFunction(() => window.__navDone);Warning
ページ内の setTimeout をフォールバックとして使用しないでください。これはブラウザ自身のイベントループの中で実行され、タイマースロットリング、ページフリーズ、タブのバックグラウンド化の影響を受けます。Playwrightの waitForFunction はページの外からポーリングし、独自のタイムアウトメカニズムを使用します — これが適切なツールです。
ハードニング版:単一の evaluate でリスナー登録とクリックをアトミックに行う
上記のインストール→トリガーのパターンには、依然として小さな隙間が残っています。page.evaluate() が解決してから page.click() が発火するまでの間に、同名の無関係なイベントが発生し、本来のクリックより先にフラグを立ててしまう可能性があるのです。ほとんどのナビゲーションではこの隙間は無視できるほど小さいものですが、無視できない場合は、リスナーの登録とクリックの実行を単一の page.evaluate 呼び出しの中にまとめてください。そうすることで、両方がページ自身のイベントループ内で同期的に実行され、その間に往復(ラウンドトリップ)が発生しなくなります:
// Hardened: listener + click atomic in a single page.evaluate call
await page.evaluate(() => {
window.__navDone = false;
addEventListener("framework:after-swap", () => {
window.__navDone = true;
});
document.querySelector('a[href="/about"], a[href="/about/"]')?.click();
});
await page.waitForFunction(() => window.__navDone);Warning
このハードニング版には、2つの注意点があります:
アクショナビリティチェックをバイパスします。 ページ内での
element.click()は、page.click()が実行前に行う可視性・安定性・ヒットターゲット・有効性のチェックをスキップします。すでにアタッチされ、表示されていることがわかっているchromeレベルの要素にのみ使用してください — テスト対象の任意のUI要素には使わないでください。hrefは末尾スラッシュを許容してマッチさせます。 SSGのビルドによって
/とabout /の出力が一貫しないことがあります。about/ 'a[href="/about"], a[href="/about/"]'のようなセレクタなら両方にマッチします。単一の値への完全一致は、静的サイトのE2Eスイートで繰り返し発生するパピーカット(小さな困りごと)です。
ステップ2 — ネットワークリクエストを発火しないナビゲーションで networkidle を待機しない
SPAのクライアントサイドナビゲーションはネットワークリクエストを発火しません。networkidle はリクエストが進行中でない瞬間に解決します — クライアントサイドナビゲーションの場合、これはルート変更が始まった直後であり、新しいビューがレンダリングされた後ではありません。
waitForLoadState("networkidle") を、実際の完了シグナルへの待機に置き換えてください:waitForURL、安定した要素へのウェブファーストのアサーション、またはステップ1のイベントキーの待機。
ステップ3 — フォールブルな待機を握りつぶさない
待機式への .catch(() => null) は、本物のタイムアウトをサイレントなグリーンに変えてしまいます:
// Anti-pattern: a timeout becomes a silent success
await page.waitForSelector(".result", { timeout: 5000 }).catch(() => null);
// Test continues as if the element appeared
// Fix: let it fail, or assert the post-condition explicitly
await expect(page.locator(".result")).toBeVisible({ timeout: 5000 });待機が本当に解決しない可能性がある場合(オプショナルな要素、条件付きUI)は、タイムアウトを握りつぶす代わりに実際の事後条件をアサートしてください。依存するものが起こらなかった場合、テストは声高に失敗すべきです。
ステップ4 — 正の完了待機:唯一合法的な waitForTimeout
正の完了待機(何かが表示されるか真になるのを待つ)では、受け入れられる waitForTimeout は次の条件を両方満たすものだけです:
文書化されたアプリケーション定数(既知のデバウンス値、ソース内で定義されたポーリングインターバル)にキーされている。
その定数を説明する
/コメントで注釈されている。/ wait- ok: <why>
標準的な例については、Playwrightパターン のエディター入力セクションに記載されている / 例外を参照してください。
Note
waitForTimeout の2つ目の正当なクラスがあります:ある時間ウィンドウの中で障害が発生しないことをアサートすること(例:「マウント後の最初の2000ms間コンソールエラーが発火しない」)。このsleepを条件待機に変換するとアサーションが骨抜きになります — ポーリングする対象となる正のイベントがないため、ポーリングは即座に解決してウィンドウの監視をやめてしまうのです。このステップのスコープは正の完了待機のみです。欠如ウィンドウアサーションにはsleepを残してください。完全なパターンについては Playwrightパターン の POST_MOUNT_LOOP_SETTLE_MS の例を参照してください。
ステップ5 — エスカレーション:本番コードに不足しているシグナルを追加する
ステップ1〜4はすべて、待機対象のシグナル — フレームワークのイベント、ハイドレーションフラグ、安定したDOM状態 — がすでに存在することを前提としています。しかし、そうでない場合もあります。待機をキーづけできる観測可能な状態遷移が存在しない場合、修正すべきはテスト側のより巧妙な待機ではありません。本番コードへの1行の追加です:
処理完了時に切り替わるデータ属性(
el.dataset.ready = "true")。トランジション後に発火するカスタムイベント(
dispatchEvent(new CustomEvent("app:ready")))。非同期の初期化後に
windowにセットされるフラグ(window.__appReady = true)。
上記のハイドレーションレースで使われている dataset.hydrated フラグは、まさにこの種のシグナルです。これはPlaywrightやブラウザのプリミティブではなく、アイランドのハイドレーションランタイムがイベントハンドラーの配線を終えた時点で、アプリケーションコードが意図的にセットするものです。同じ規約は、このガイドの他の場所にあるハイドレーションのミスネスト変種でも [data-island] 上の data-hydrated として登場します — 1つのアイランドライフサイクル属性が、2つの異なるテストで2つの異なる目的のために読み取られているのです。
テストできない待機は、仕様の問題ではなく、プロダクトコードの観測可能性のギャップです。シグナルが存在すれば、ステップ1と同じ方法で待機してください:アクションをトリガーする前にリスナーをインストールするかポーリングを開始し、トリガーし、その後で待機します。
Note
締めくくりの不変条件:すべての待機は、時間の長さではなく観測可能な状態遷移にキーづけされていなければなりません。 待機を本番コードが発するシグナルに結びつけられない場合、修正すべきなのは本番コードであり、より長いタイムアウトではありません。そして、正しくイベントキーされた待機でも、シグナル自体がリソース飢餓に陥っていればタイムアウトしえます — それはカタログのエントリ6、つまりアプリ/配信の問題であって、待機の問題ではありません。
Before / After — まとめ
よくある不安定なテストは、両方のアンチパターンを同時に組み合わせています:
// BEFORE — flaky: timing guess + networkidle on a SPA nav
test("navigates to dashboard", async ({ page }) => {
await page.goto("/");
await page.click("a[href='/dashboard']");
await page.waitForLoadState("networkidle"); // resolves before the view renders
await page.waitForTimeout(500); // timing guess
await expect(page.locator("h1")).toHaveText("Dashboard");
});// AFTER — deterministic: event-keyed + web-first assertion
test("navigates to dashboard", async ({ page }) => {
await page.goto("/");
// Install listener BEFORE the action
await page.evaluate(() => {
window.__navDone = false;
addEventListener("framework:after-swap", () => {
window.__navDone = true;
});
});
await page.click("a[href='/dashboard']");
// Await the app's own signal using Playwright's timeout
await page.waitForFunction(() => window.__navDone);
// Web-first assertion as the final guard
await expect(page.locator("h1")).toHaveText("Dashboard");
});関連ページ
Playwrightパターン でPlaywrightセットアップの全パターンを、実行ティア でフレイクがタイミング問題ではなくトポロジ問題である場合のガイダンスを参照してください。