すべてはSlackのメッセージから始まります。ビルドが赤(失敗)になっています。失敗した箇所をスクロールして眉をひそめ、自分のノートPCで同じテストを再実行します。結果は緑(成功)。「一時的なものだったのかも」と思い、CIジョブを再試行します。しかし、失敗は再び発生します。サーバー上では執拗に、かつ再現性を持って繰り返されるのに、手元では見えないのです。

CIでは失敗するがローカルではパスするブラウザテストは、単なる苛立ち以上の問題を引き起こします。それは不信感を生みます。チームはタイミングのせいにし始めます。場当たり的な修正を次々と投入し、それがそのまま残っていきます。ここに setTimeout を入れ、あそこに .wait(5000) を入れる。テストスイートは遅くなり、失敗は繰り返されます。そうして不安定なテスト(flaky tests)は恒久的なものとなり、やがて誰もが「赤いパイプライン」を背景ノイズのように扱い始めます。

それは危険なことです。「狼少年」のようなテストスイートは、あってはならないのです。

CIが壊れているのではない。ただ、環境が違うだけだ

CI環境はランダムではありません。決定論的(deterministic)です。問題は、CIが「あなたのMacBookやLinuxワークステーションとは異なるシステム」に対して決定論的に動いていることです。ローカルの設定では隠れている差異が、クリーンなCIランナーでは即座に露呈します。

どれほど多くの要素が食い違っているかを考えてみてください。ローカルマシンではホットモジュールリロード(HMR)を伴う開発サーバーが動いているかもしれませんが、CIではツリーシェイキングやミニファイ化が行われたプロダクション用アーティファクトをビルドしています。それだけでコードパスが削られたり、実行順序が変わったりすることがあります。依存関係のツリーも変化します。ロックファイルが全く同じに見えても、パッケージマネージャーのバージョンがマイナーリリース一つ違うだけで、解決結果が変わることがあります。ネットワークのシーケンスも変わります。オフィスのWi-FiではステージングAPIに1ホップで到達できるかもしれませんが、CIランナーはロードバランサーの背後にある別のクラスターに接続し、手元では決して目にすることのないレイテンシ(遅延)を発生させるかもしれません。

ブラウザ自体の挙動も、環境によって異なります。ローカルのChromeには拡張機能、キャッシュされた認証情報、永続的なローカルストレージ、そしてハードウェアアクセラレーションを備えたGPUが存在します。一方、CIは実行のたびに空のプロファイルから開始されます。ブラウザのライフサイクルも、レンダリングパスも異なります。システムに存在するフォントが、CIでは代替フォントに置き換わることもあります。ビューポートのサイズやデバイスピクセル比も異なり、それによってレスポンシブのブレイクポイントが切り替わったり、遅延読み込み(lazy-loading)の挙動が変わったりすることがあります。

これらのギャップは実在します。それはメカニカルな(構造的な)問題です。これらを「ランダムなもの」だと思い込んでも、解決にはなりません。

プレビュー環境は嘘をつく

プレビュー環境は問題をさらに複雑にします。人間によるレビューには有用ですが、プロダクション環境ではありません。多くの場合、本物のAPIホストではなく api-staging を指しています。フィーチャーフラグはすべての実験に対して true と評価され、プロダクションで実行される条件付きロジックが隠されてしまいます。認証プロセスがステップをスキップしたり、モックのトークンを注入したりすることもあります。Cookieのポリシーが緩和されているかもしれません。データセットが、1万行ではなくわずか10行しかないような薄いスライスである場合、ページネーション、検索ランキング、あるいは仮想化(virtualization)のロジックが一度も実行されないことを意味します。

プレビューURLに対してテストがパスするのにプロダクションで失敗する、あるいはその逆の場合、バグがあるのはテストではなく、環境の方です。

推測する前にログを取れ

失敗が初めて現れたとき、テストを微調整して「当たればいいな」と願う本能に抗ってください。推測するのはやめましょう。パスした実行結果と失敗した実行結果を比較できるように、コンテキストを固定する必要があります。

明らかな容疑者たちのログを記録してください。失敗した瞬間のページURL、ビルドID、コミットSHAを記録します。有効なフィーチャーフラグもメモしてください。APIホスト、正確なブラウザのバージョン、ビューポートのサイズをキャプチャします。これらの詳細が、謎めいた失敗を「再現可能な条件」へと変えてくれます。

スクリーンショットだけに頼ってはいけません。2つのページは、全く異なるJavaScriptを実行していても、見た目上はピクセル単位で同一に見えることがあります。スクリーンショットでは、CIのバンドルに余分なポリフィルが含まれていたことや、ローカルのバンドルがブラウザキャッシュにあるために特定のチャンクをスキップしたことまでは分かりません。

また、DevToolsを開くこと自体がタイミングを変えてしまうことも忘れないでください。DevToolsはガベージコレクションを遅延させ、ネットワークの優先順位を変更し、特定のレンダリング最適化を無効にすることがあります。DOMを検査している間はパスするテストが、パネルを閉じてヘッドレスで実行した瞬間に失敗することもあります。デバッガーは有用なツールですが、中立な観察者ではありません。

犯行現場を再現せよ

失敗を正確に再現したいのであれば、単にローカルの開発サーバーを動かして「うまくいくことを祈る」だけでは不十分です。CIと全く同じ条件を再現する必要があるのです。

CIが生成したのと全く同じアーティファクトをビルドしてください。必要であればダウンロードしてください。ViteやWebpackのデブ・ミドルウェアではなく、シンプルな静的ファイルサーバーを使用して、そのアーティファクトをローカルで配信してください。CIが注入したのと同じ環境変数を使用してください。ブラウザのバージョンも正確に一致させてください。ヘッドフル(headed)かヘッドレス(headless)か、同じモードで実行してください。フォーカスイベント、メディアクエリ、オートプレイ(自動再生)ポリシーなどは、依然として両者の間で微妙に異なるためです。CIでDockerコンテナを使用している場合は、ローカルでも同じイメージを実行してください。個人のブラウザプロファイルは完全に削除してください。

ローカルでの再現がようやく失敗したとき、初めて本当のデバッグセッションが始まります。それまでは、影を追いかけているに過ぎません。

「スリープ」をやめ、「待機」を始めよう

フレーキー(不安定)なブラウザテストに対する最も一般的な対応は、遅延を追加することです。5秒待つ。10秒待つ。これは解決策ではありません。降伏です。恣意的な遅延はテストスイートを遅くし、誤った安心感を与え、ネットワークが不安定になった際の負荷がかかった状況下では依然として失敗します。

代わりに、状態の証拠を待ってください。フォーム送信後に通知が表示されるはずなら、時間が経過するのを待つのではなく、特定の通知IDがDOM内に存在することを待ってください。カウンターが増えるはずなら、テキストの値が変わるのを待ってください。ローディング状態で操作がブロックされるなら、ローディングマーカーが消えるのを待ってください。WebSocketやServer-Sent Events(SSE)を扱っている場合は、ネットワークストリームが特定のイベントを生成するのを待ってください。

明示的な待機(Explicit waits)は、テストを「当てずっぽう」から「契約」へと変えます。テストはこう言います。「アプリケーションが準備完了を通知したら、次に進む」。これは、「十分な秒数が経過したら、次に進む」と言うよりもはるかに強力です。

ハイドレーションと消えるボタン

モダンなReactアプリケーションにおいて、ハイドレーションは、ローカルの開発サーバーでは隠されてしまいがちな、特定の種類の失敗を引き起こします。サーバーがHTMLを送信します。ブラウザでReactが起動し、イベントリスナーをアタッチします。そのわずかな間に、テストがボタンをクリックしてしまうことがあります。すると、Reactはハイドレーション中にそのDOMノードを置換または再構成します。テストフレームワークが保持していた要素のハンドルは、切り離された(detached)ノードを指すことになり、「削除された要素に対して操作を行おうとした」というエラーが発生します。

解決策は、コンポーネントツリーをより深く掘り下げるような、より複雑なセレクターを書くことではありません。解決策は、準備完了のシグナルを探すことです。ルート要素がハイドレーション済みの属性や既知のデータプロパティを取得するまで待ってください。スケルトンローダーが消えるのを待ってください。クライアント側のイベントハンドラーがアクティブになるのを待ってください。クリックを実行する前に、アプリケーションが安定したことを宣言させるのです。

隠れた犯人:依存関係とサードパーティ製スクリプト

アプリケーションのコードを変更していなくても、環境が変わってしまうことがあります。node_modulesの3階層下にある小さなユーティリティライブラリの推移的更新(transitive update)が、ブラウザの挙動を変えてしまう可能性があるのです。プロミスが解決される方法、スタイルが注入される方法、あるいはモックがリクエストをインターセプトする方法が変わるかもしれません。定期的な依存関係の更新後にテストが失敗し始めたら、パッケージマネージャーのバージョンとロックファイルのチェックサムを記録してください。先週と同じツリーを見ているのかどうかを知る必要があるからです。

サードパーティ製のスクリプトも、頻繁に起こる破壊工作の要因です。分析トラッカー、決済SDK、チャットウィジェットなどは非同期にロードされます。これらは、テストが想定していない瞬間にiframeを注入したり、レイアウトをずらしたり、フォーカスを奪ったりします。CIでは、これらのスクリプトのロードが遅くなったり、ネットワーク制限によってロード自体に失敗したりすることがあり、その結果、アプリケーションが異なるエラーハンドリングのパスを辿ることがあります。どのサードパーティリソースがロードされたかと、そのHTTPステータスをログに記録してください。もし決済用のiframeがCIではマウントに3秒かかるのに、高速なローカル接続では瞬時にロードされるのであれば、「element not clickable」というエラーの明確な原因が判明します。

そして、「element not clickable」は決して診断名ではありません。それは症状です。原因に対処してください。

エビデンス・キットを構築する

すべてのCIの失敗は、具体的なアクションに繋がるものであるべきです。スタックトレースだけでは不十分です。他のエンジニア、あるいは来月の自分自身が何が起きたのかを再構成できるような「エビデンス・キット」が必要です。

失敗した実行時のスクリーンショットとビデオ録画を保存してください。エラーだけでなく、警告も含めたブラウザのコンソール出力をすべてキャプチャしてください。404、CORS拒否、接続断など、ネットワークの失敗をログに記録してください。有効だったビルドIDやフィーチャーフラグを保持してください。アサーションが失敗したまさにその瞬間のDOMスナップショットを撮ってください。スナップショットがあれば、事後にHTML構造を調査することができ、むしろ