ブラウザテストが不安定になると、最初に疑われるのはテストコードです。
待機時間が短いのではないか。
セレクターが不安定なのではないか。
CIのマシンが遅いのではないか。
もちろん、それらが原因の場合もあります。しかし最近のWebアプリケーションでは、失敗の原因がテストコードの外側にあることも少なくありません。
Reactの部分レンダリング、Webフォント、Feature Flag、ブラウザのCookie制限、動的な検索条件、ARIA Live Regionなど、アプリケーション自体が時間とともに変化するようになったからです。
その結果、表面上は同じ「要素が見つからない」「クリックできない」「期待値と違う」という失敗でも、原因はまったく異なります。
この記事では、現代のブラウザテストでFlakyを生みやすい8つの原因と、実際にどう切り分ければよいかを整理します。
1. Reactの画面は「表示された瞬間」に完成しているとは限らない
React Server Components、Suspense、Streaming UIを使った画面では、ページ全体が一度に完成するとは限りません。
たとえば、次のような順番でUIが更新されることがあります。
- ページの基本構造が表示される
- ローディング用のSkeletonが表示される
- サーバーから一部のデータが届く
- ボタンが有効になる
- 関連情報が後から追加される
このとき、テストが「ページが表示された直後」にDOMを検証すると、正常な中間状態を失敗として扱ってしまいます。
よくある対策は固定時間の待機です。
await page.waitForTimeout(3000);
しかし、これは問題を隠しているだけです。
ローカルでは3秒で十分でも、CIでは4秒かかるかもしれません。反対に、毎回3秒待つことでテスト全体が必要以上に遅くなります。
固定時間ではなく、ユーザーにとって意味のある状態を待つべきです。
await expect(
page.getByRole("button", { name: "注文する" })
).toBeEnabled();
重要なのは、Reactが何回レンダリングしたかではありません。
ユーザーが操作できる状態になったかどうかです。
React Server Componentsや部分的な再レンダリングをテストする際の考え方は、以下の記事でも詳しく説明されています。
- How to Test React Server Components, Streaming Updates, and Partial Re-Renders Without Chasing Hydration Noise
- What to Look for in a Browser Testing Tool for React Server Components, Streaming UI, and Partial Re-Renders
切り分けのポイント
-
networkidleだけに依存しない - Skeletonの消滅ではなく、操作可能な状態を待つ
- DOM構造ではなくユーザーが認識できる結果を検証する
- 入力値やフォーカスが再レンダリングで失われていないか確認する
2. Webフォントや絵文字がクリック位置を変えている
フォントは見た目だけの問題だと思われがちですが、ブラウザテストでは操作にも影響します。
画面の初期表示時にはフォールバックフォントが使われ、その後でWebフォントに切り替わることがあります。
フォントが変わると、文字幅や行の高さも変わります。
その結果、次のような現象が起こります。
- ボタンが次の行に移動する
- 要素の高さが変わる
- クリック直前に対象が移動する
- Tooltipの位置がずれる
- スクリーンショット比較が失敗する
絵文字も同様です。
OSやブラウザによって異なる絵文字フォントが使用されるため、ローカルとCIでレイアウトが変わることがあります。
フォントの読み込みを待つ場合は、ブラウザ側で次のように確認できます。
await page.evaluate(async () => {
await document.fonts.ready;
});
ただし、document.fonts.readyだけで十分とは限りません。
フォント読み込み後にレイアウト計算やアニメーションが発生する場合もあるため、対象要素の位置が安定したことを確認する必要があります。
この問題の詳細は、以下の記事が参考になります。
切り分けのポイント
- CIとローカルで同じフォントが利用可能か確認する
- Webフォント読み込み完了後に操作する
- スクリーンショット撮影前にレイアウトシフトを待つ
- 絵文字やVariable Fontが含まれる画面を別環境でも確認する
3. Feature Flagによって同じURLが別の画面になる
Feature FlagやA/Bテストを使っている場合、同じURLでもユーザーによって異なるUIが表示されます。
たとえば、あるテストでは次のボタンを期待しているとします。
<button>購入する</button>
しかし別のExperiment Bucketでは、テキストが次のように変わっているかもしれません。
<button>今すぐ注文</button>
さらに厄介なのは、Feature Flagの設定が環境ごとに異なるケースです。
- ローカルでは新UI
- Stagingでは旧UI
- CIのテストユーザーだけ別Bucket
- Productionでは地域によって異なる設定
この状態でテストが失敗すると、セレクターの問題に見えます。
しかし実際には、テストが想定していたアプリケーション状態と、実行時の設定が一致していません。
Feature Flagがテストの安定性に与える影響については、以下の記事で整理されています。
改善方法
テスト結果に、関連するFeature Flagの状態を保存します。
{
"new_checkout": true,
"recommended_products_v2": false,
"experiment_bucket": "B"
}
失敗時にこの情報が残っていれば、単なるFlakyなのか、別UIをテストしていたのかをすぐに判断できます。
切り分けのポイント
- テストユーザーのExperiment Bucketを固定する
- 実行時のFeature Flagをログに保存する
- Flagごとに期待するUIを明示する
- テスト環境と本番環境の設定差を定期的に確認する
4. ブラウザごとのCookie制限が認証フローを壊している
Chrome、Edge、Safariでは、サードパーティCookieやトラッキング防止機能の挙動が異なります。
この違いは、特に次のような機能に影響します。
- SSO
- 外部決済
- 埋め込みフォーム
- サポートチャット
- クロスドメイン認証
- iframe内のセッション
たとえば、Chromeでは成功するログインフローが、Safariではセッションを維持できずにログイン画面へ戻ることがあります。
この場合、テストコード自体は正常です。
アプリケーションがブラウザのプライバシー制限に対応できていない可能性があります。
ブラウザごとのCookie問題を再現する方法は、以下の記事で紹介されています。
テストすべき内容
単にCookieが存在するかではなく、ユーザーフロー全体を確認します。
- メインサイトからログインを開始する
- 外部ドメインへ遷移する
- 認証を完了する
- 元のページへ戻る
- セッションが維持されていることを確認する
- Cookieが使えない場合でも回復可能か確認する
切り分けのポイント
- ChromeだけでなくSafariでも実行する
- iframeとトップレベル遷移を分けて検証する
- SameSite属性を確認する
- 認証失敗時のリダイレクト履歴を保存する
- Cookieが無効な状態での回復フローもテストする
5. Search、Filter、Sortの状態が同期していない
検索画面では、単体の操作よりも組み合わせで問題が起きます。
たとえば、次のテストは簡単です。
- キーワード検索が動く
- カテゴリフィルターが動く
- 並び替えが動く
- ページネーションが動く
しかし、実際のバグは次のような組み合わせで発生します。
- 並び替えるとフィルターが解除される
- 検索条件を変更してもページ番号が維持される
- URLと画面上のフィルターが一致しない
- Back操作で検索条件の一部だけが復元される
- 結果一覧は更新されたが件数表示が古い
このような画面では、個別のボタンをテストするだけでは不十分です。
検索状態を一つのオブジェクトとして考える必要があります。
const expectedState = {
query: "playwright",
category: "testing",
sort: "newest",
page: 2
};
そのうえで、以下が一致しているかを検証します。
- URL
- 入力欄
- 選択済みフィルター
- 結果一覧
- 結果件数
- ページ番号
Search、Filter、Sortのワークフローをテストする際の注意点は、以下の記事でも解説されています。
切り分けのポイント
- URLとUIの状態を同時に検証する
- Back、Forward、Reloadを含める
- 空の結果や競合するフィルターもテストする
- 結果件数と実際の一覧件数を比較する
- セレクターの問題と状態管理の問題を分ける
6. Accessibilityの失敗はスクリーンショットに映らない
画面が見た目どおりに表示されていても、キーボードやスクリーンリーダーでは操作できない場合があります。
特に動的UIでは、次のような問題が発生します。
- Modalを開いてもフォーカスが移動しない
- Modalの外側へTab移動できてしまう
- エラーメッセージが読み上げられない
- ローディング完了が通知されない
- 同じLive Regionが何度も読み上げられる
- 非表示要素がアクセシビリティツリーに残る
これらは静的なHTMLスキャンだけでは検出しにくい問題です。
実際にキーボード操作を行い、状態変化を確認する必要があります。
await page.keyboard.press("Tab");
await expect(page.getByRole("dialog")).toContainText("設定");
ARIA Live RegionやFocus Trapのテストについては、以下の記事が参考になります。
切り分けのポイント
- Modalを開いた直後のフォーカスを確認する
- TabキーでModal外へ移動しないことを確認する
- Modalを閉じた後のフォーカス復帰先を確認する
- エラーや完了メッセージが適切に通知されるか確認する
- 部分レンダリング後に重複した通知が発生していないか確認する
7. CIログを増やしすぎると原因が見えなくなる
Flakyテストが増えると、多くのチームはログを追加します。
- ブラウザコンソール
- Network Log
- DOM Snapshot
- Screenshot
- Video
- Trace
- API Response
- HTML Report
情報が増えれば調査しやすくなるように思えます。
しかし、実際にはログが多すぎることで、重要な情報が埋もれることがあります。
CIの出力が数千行になり、誰も最後まで読まなくなる状態です。
有効なFlake Triageでは、情報を階層化します。
最初に表示する情報
- 失敗したテスト名
- 失敗したステップ
- ExpectedとActual
- Screenshot
- Browser
- Environment
- Commit
- Retry結果
必要に応じて確認する情報
- Network Trace
- Console Log
- DOM Snapshot
- Video
- Raw CI Log
CIログを単なるデータ置き場にしないための考え方は、以下の記事で詳しく説明されています。
切り分けのポイント
- 失敗の要約を最初に表示する
- Screenshotに実行IDやステップ番号を含める
- Retry前後の差分を保存する
- すべてのログを同じ画面に表示しない
- 調査に使われていないログは削除する
8. 「再実行したら成功」は解決ではない
Flakyテストに対してよく使われる方法がRetryです。
retries: 2
Retry自体は悪くありません。
ネットワークや外部サービスの一時的な問題を吸収するために有効です。
ただし、Retryで成功したテストを完全な成功として扱うと、問題が蓄積します。
たとえば、10回に1回失敗するテストが100本あれば、CIではほぼ毎回どこかが失敗します。
Retryによって最終結果はGreenになっても、テストスイートの信頼性は下がり続けます。
Flakyなテストは、少なくとも次のカテゴリに分類すると調査しやすくなります。
- Product Bug
- Test Bug
- Environment Issue
- Test Data Issue
- Browser Difference
- Known External Dependency
- Unknown
さらに、誰が最初に分類するのかを決めておく必要があります。
担当が不明確だと、次のようなやり取りが発生します。
- QA:「Frontendの問題だと思います」
- Frontend:「APIのレスポンスです」
- Backend:「テストデータが原因です」
- DevOps:「環境は正常です」
最終的に再実行して成功し、原因不明のまま終了します。
これはFlakyテストを直したのではなく、議論を止めただけです。
実践的な切り分け手順
ブラウザテストが失敗したときは、すぐに待機時間を増やすのではなく、次の順番で確認します。
Step 1: 同じ条件で再現する
以下を固定します。
- Browser
- Browser Version
- OS
- Viewport
- Locale
- Timezone
- Test Data
- Feature Flag
- Experiment Bucket
条件が違えば、同じテストでも別の結果になります。
Step 2: 失敗した瞬間の状態を保存する
最低限、次の情報を保存します。
{
"test": "checkout preserves discount after payment retry",
"browser": "webkit",
"environment": "staging",
"commit": "abc123",
"feature_flags": {
"new_checkout": true
},
"retry": 1
}
加えて、ScreenshotやTraceも実行IDと関連付けます。
Step 3: 固定待機を入れる前に条件を特定する
次のような待機は最後の手段です。
await page.waitForTimeout(5000);
代わりに、何を待つべきかを特定します。
- フォント読み込み
- ボタンの有効化
- Skeletonの消滅
- APIレスポンス
- URL変更
- フォーカス移動
- Live Regionの更新
- レイアウトの安定
Step 4: Retry結果を記録する
Retryで成功した場合も、最初の失敗を保存します。
- 初回失敗
- 2回目成功
- Screenshot差分
- 実行時間差
- Network差分
これにより、単なる一時的な遅延なのか、状態依存の問題なのかを判断しやすくなります。
Step 5: テスト名をユーザーへの影響で表現する
悪い例:
checkout test 12
良い例:
payment retry preserves applied discount
悪い例:
filter test
良い例:
back navigation restores query and selected filters
テスト名にユーザーへの影響が含まれていれば、失敗時の優先度も判断しやすくなります。
まとめ
現代のブラウザテストで発生するFlakyは、単純な待機不足やセレクターの問題だけではありません。
原因は、次のような場所にも存在します。
- ReactのStreaming UIと部分レンダリング
- Webフォントとレイアウトシフト
- Feature FlagとExperiment Bucket
- ブラウザごとのCookie制限
- Search、Filter、Sortの状態同期
- ARIA Live RegionとFocus管理
- 過剰なCIログ
- Retryによって隠された問題
重要なのは、失敗したテストに待機時間を追加することではありません。
ユーザーにとって、どの状態が安定しているべきなのかを定義することです。
DOMが何回更新されたかではなく、ユーザーの入力が維持されたか。
NetworkがIdleになったかではなく、操作できる状態になったか。
スクリーンショットが完全一致したかではなく、必要な情報が正しく伝わったか。
ブラウザテストを安定させる最も効果的な方法は、すべての変化を止めることではありません。
変化しても守られるべき契約を明確にすることです。
