「Internet Explorerで動いていたのに、Chromeで壊れた」「Safariでは正常だが、Firefoxでレイアウトが崩れる」
こうしたブラウザ間の動作差に悩まされた経験は、JavaScriptエンジニアなら誰しも一度はあるでしょう。
ECMAScriptの仕様が整備された現代でも、ブラウザベンダーごとのエンジン(V8、SpiderMonkey、JavaScriptCore)の実装差分や、Web APIのサポート状況、さらにはレンダリングパイプラインの微妙なタイミング差は、本番環境で予期せぬ障害を引き起こし続けています。
重要なのは、この問題を「仕方ない」で済ませず、設計段階からテスト戦略に組み込むことです。
互換性問題の多くは、仕様上の曖昧さや、ブラウザが許容する非標準動作に依存した実装が原因です。
たとえば、Array.prototype.forEachのコールバック内での非同期処理や、requestAnimationFrameのコールバック実行間隔、addEventListenerの第3引数(passiveオプション)のデフォルト値などは、ブラウザバージョンによって挙動が異なる代表例です。
そこで有効なのが、レイヤードテストアプローチです。
単体テストでは、ポリフィルやトランスパイラ(Babel)で吸収できないコアロジックに絞り、JestやVitestなどのNode環境で仕様準拠を検証します。
次に、統合テストではPlaywrightやWebDriverIOを用いて、実際のブラウザスナップショットに対して動作検証を実施します。
この際、以下の3点を徹底してください。
- ブラウザマトリクスの明確化:サポート対象を、利用統計とプロジェクト要件から絞り込み、最新版+直近2バージョン+LTSリリースに限定する
- フィーチャーディテクションの実装:
if('Promise' in window)のような存在チェックではなく、try-catchで実際の動作を確認するプローブ関数を用意する - スナップショットテストの併用:レンダリング結果を画像比較ではなく、DOM構造のシリアライズ差分で判定し、不要なアラートを減らす
さらに、CI/CDパイプラインでは、並列実行と失敗時の再試行を設定し、ネットワーク遅延やリソース読み込みのバラつきをノイズとして除外します。
例えば、waitForSelectorのタイムアウト値を環境変数で切り替え、開発環境では短めに、本番検証では長めに設定するといった調整が効果的です。
| テスト種別 | 実行環境 | 主な検証対象 | 推奨ツール | 実行頻度 |
|---|---|---|---|---|
| 単体テスト | Node.js | コアアルゴリズム、型変換 | Vitest | PR作成時 |
| 統合テスト | 実ブラウザ | DOM操作、イベントハンドリング | Playwright | マージ時 |
| E2Eテスト | 実ブラウザ | ユーザーフロー、API連携 | WebDriverIO | リリース前 |
最後に、デバッグ容易性を高めるために、ブラウザ固有の挙動をログ出力するヘルパーを実装し、テスト失敗時にnavigator.userAgentと実行環境のメタデータを自動収集する仕組みを組み込みます。
こうした対策を積み重ねれば、「ブラウザ依存」は恐れるべきリスクではなく、制御可能な変数としてテスト計画に組み込めるようになります。
互換性問題はゼロにはなりませんが、検出から修正までのリードタイムを劇的に短縮できるはずです。
なぜブラウザごとにJavaScriptの動作が異なるのか? 仕様と実装のギャップを理解する

JavaScriptの互換性問題の根源は、ECMAScript仕様という「文章」と、各ブラウザエンジンという「実装」の間に必ず存在する解釈の幅にあります。
ECMAScript仕様はTC39委員会によって策定され、文法や型変換、組み込みオブジェクトの動作を詳細に記述していますが、仕様はあくまで「期待される振る舞い」を言語化したものであり、実装におけるメモリ管理や最適化、例外発生時のスタックトレースまでを一意に定めているわけではありません。
まず、代表的なブラウザエンジンであるV8(Chrome/Edge)、SpiderMonkey(Firefox)、JavaScriptCore(Safari) は、それぞれ別個のコードベースを持ち、C++やCで実装されています。
これらは仕様に準拠することを目標としながらも、以下のような要因で動作差が生じます。
- 仕様の曖昧さ:仕様が「実装依存」と明記している箇所。例えば、
Array.prototype.sortの安定性はES2019で安定ソートが義務化されるまでは実装依存でした。また、正規表現の後読みサポートやSymbolの列挙順序なども、バージョンによって実装状況が異なります - 最適化コンパイラの戦略差:V8はTurboFan、SpiderMonkeyはIonMonkey、JavaScriptCoreはFTLという最適化コンパイラを持ちます。これらはホットな関数をJITコンパイルしますが、インライン展開の閾値や型推論の精度が異なるため、パフォーマンスだけでなく、
argumentsオブジェクトの振る舞いやdelete演算子のパフォーマンス特性にも違いが出ます - Web APIの実装差分:
fetchのタイムアウトデフォルト値、WebSocketの再接続挙動、IntersectionObserverのコールバック実行タイミングなどは、仕様で厳密に定義されていないか、あるいはベンダー拡張として追加されたプロパティが存在します。特に、addEventListenerのpassiveオプションのデフォルト値は、Chromeではタッチイベントに対してtrueですが、Safariの古いバージョンでは無視されるケースがありました
仕様準拠テストスイートの限界
ブラウザベンダーはTest262という公式の適合性テストスイートを継続的に実行し、仕様への準拠率を高めています。
しかし、Test262は「仕様に書かれていることを正しく実装しているか」 を検証するものであり、「実装が仕様の意図しない副作用を持っていないか」 までは保証しません。
たとえば、try-catchブロック内でのスタックフレーム管理や、ガベージコレクションのタイミングがテストケースに影響を与えることは稀ですが、本番アプリではメモリリークやパフォーマンス低下として顕在化します。
また、Test262はあくまで言語コアに焦点を当てており、DOM APIやWindowオブジェクト、localStorage、Service Workerといったホスト環境が提供するAPI群は、別途Web Platform Tests(WPT)でカバーされています。
しかしWPTでさえ、すべてのブラウザが全テストをパスしているわけではなく、特にエッジケースやレガシー機能では実装が分かれます。
バージョンアップデートがもたらす非互換性
もう一つの大きな要因は、ブラウザの自動アップデートとEnterprise環境のバージョン固定の衝突です。
Chromeは約6週間ごとにメジャーアップデートをリリースし、FirefoxやSafariも同様のペースで進化します。
しかし、大企業の社内システムでは、セキュリティポリシーにより特定バージョンに固定されているケースが多く、その場合、最新のJavaScript構文(Optional ChainingやNullish Coalescing)がトランスパイルされていても、ポリフィルでカバーできないランタイム動作が問題になります。
具体例として、Intl.DateTimeFormatのロケールフォーマットは、ブラウザのICU(International Components for Unicode)バージョンに依存します。
そのため、同じコードでもChrome 90とChrome 120では日本語の日付表記が微妙に異なることがあります。
これはテストで発見しづらく、ユーザーからの報告で初めて気づく類のバグです。
レンダリングエンジンとイベントループの差異
JavaScriptの実行モデルそのものはイベントループという共通概念に基づいていますが、マイクロタスクとマクロタスクの処理順序はブラウザ間で完全に一致しているわけではありません。
Promise.thenとsetTimeoutの優先順位は仕様で定義されていますが、MutationObserverのコールバックやrequestAnimationFrameのスケジューリングは、ブラウザのフレームレート管理やアクティブタブのバックグラウンド状態によって変動します。
特に、バックグラウンドタブではrequestAnimationFrameが間引きされる挙動がブラウザごとに異なるため、アニメーションループを使ったゲームやスクロール連動処理では顕著な差が生じます。
以上の点から、「仕様に準拠している」と「ブラウザ間で同じように動く」は必ずしも同義ではないという事実を、テスト設計の前提として認識しておく必要があります。
このギャップを埋めるには、単なる構文チェックではなく、実行コンテキストやホスト環境の特性を考慮した多層的なアプローチが欠かせません。
次の見出しでは、この理解を踏まえた上で、静的解析の段階からどのように対処していくかを具体的に掘り下げていきます。
互換性問題を事前に特定するための静的解析とLintルールの活用法

ブラウザ間の動作差を実行時まで放置すると、デバッグコストが指数関数的に増大します。
そこで有効なのが、コードが実行される前に潜在的な非互換性を検出する静的解析です。
JavaScriptエコシステムではESLintが事実上の標準であり、適切に設定されたルールセットは、仕様上の危うい構文やブラウザサポートが不均一なAPIを早期に警告します。
ただし、デフォルトの推奨ルールだけでは不十分であり、ブラウザ互換性に特化したプラグインと設定を組み込むことで、初めて実用的なガードレールになります。
ブラウサリリストとESLintの連携
まず押さえるべきは、browserslistという設定ファイルです。
これは、プロジェクトがサポートするブラウザのバージョン範囲を記述するもので、AutoprefixerやBabelでも利用されます。
ESLintでは、eslint-plugin-compatというプラグインを用いて、このbrowserslist定義と連携できます。
このプラグインは、指定されたブラウザバージョンで未サポートのAPIや構文を検出し、該当箇所をエラーまたは警告として報告します。
例えば、browserslistに"last 2 Chrome versions"と"Firefox ESR"を指定している場合、Array.prototype.atやString.prototype.replaceAllが古いFirefoxで動作しないことを、コードを書いた瞬間に知らせてくれます。
これにより、「動くと思っていたのに本番で壊れた」という事態をコミット前に回避できます。
ルールのカスタマイズと厳格化のポイント
ただし、eslint-plugin-compatだけではカバーしきれないケースもあります。
それは、構文的には問題ないが、実行時の型やプロトタイプチェーンに依存した非互換性です。
たとえば、instanceof演算子はiframe間で異なるグローバルオブジェクトが存在する場合に予期せぬ結果を返します。
また、Array.isArrayはES5で導入されたため古いブラウザではポリフィルが必要ですが、構文チェックだけでは検出されません。
そこで、以下のような補完的なルールセットを導入することを推奨します。
eslint-plugin-unicorn:モダンなJavaScript推奨構文を強制すると同時に、ブラウザ互換性が低いパターン(例:new Array(n)のようなコンストラクタ呼び出し)を警告eslint-plugin-no-unsanitized:DOMに直接HTMLを挿入するinnerHTMLやdocument.writeの使用を制限し、XSSリスクとともにブラウザごとのサニタイズ動作差を回避eslint-plugin-security:evalやFunctionコンストラクタなど、ブラウザによって制限レベルが異なる危険関数の使用を検出
これらのルールを組み合わせる際には、エラーレベルを段階的に設定することが実践的です。
開発初期は警告(warn)に留め、リリース候補ブランチではエラー(error)に引き上げることで、開発速度と品質担保を両立できます。
型情報を利用した高度な静的解析
ESLintはデフォルトでは型情報を持たないため、動的なプロパティアクセス(例:obj[methodName]())の互換性評価は苦手です。
ここで活躍するのが、TypeScriptの型チェッカーと連携したESLintルールです。
@typescript-eslintパッケージを導入すれば、型情報を基にした以下のような解析が可能になります。
- 存在しないプロパティへのアクセスを事前にブロック(例:
document.allはレガシーブラウザ以外では非推奨) - ユニオン型の分岐処理が漏れていないか検証し、ブラウザごとの分岐実装を強制
@ts-expect-errorコメントの適切な利用を監査し、意図しない互換性回避を防止
型情報を活用する場合、parserOptions.projectにtsconfig.jsonを指定し、@typescript-eslint/recommended-requiring-type-checkingルールセットを追加します。
これにより、実行時エラーになりうる型強制変換や、any型経由でのAPI呼び出しを未然に検出できます。
ただし、このレベルではビルド時間が増加するため、CI環境でのみ有効にするか、プリコミットフックで軽量なチェックだけ走らせるなど、トレードオフを考慮してください。
静的解析の限界と動的テストへの橋渡し
ここで重要なのは、静的解析は「可能性のある問題」を指摘するに過ぎないという点です。
たとえすべてのLintルールをパスしても、ブラウザのガベージコレクションタイミングや、CSSOMとの相互作用によるレイアウトスラッシングまでは検出できません。
したがって、Lintは最初のフィルタリングとして位置付け、警告がゼロになった段階で、次の単体テストフェーズに進むというフローを確立するのが合理的です。
また、Lintルールを過剰に厳しくすると、開発者の生産性を損なうリスクもあります。
サポート対象ブラウザが限定的なプロジェクトでは、eslint-plugin-compatのignoreリストに既知の非互換APIを追加し、意図的に許容する判断も重要です。
その判断をeslint-disableコメントで明示し、レビュー時に理由を共有する文化を育てれば、静的解析は単なる「お仕置きツール」ではなく、設計意図を伝えるドキュメントとしても機能します。
次のセクションでは、この静的解析を通過したコードに対して、単体テストレベルでどのように仕様準拠を検証していくか、特に非同期処理の扱いを中心に掘り下げます。
単体テストで仕様準拠を検証する際の注意点と非同期処理の落とし穴

静的解析を通過したコードでも、単体テスト段階で仕様準拠を厳密に検証する必要があります。
なぜなら、Lintは構文と型の表面的な整合性を見るのに対し、単体テストは実際の実行時の振る舞い、特に非同期処理や副作用の順序を検証できるからです。
しかし、ここで多くの開発者が陥るのが、「Node環境で動くからブラウザでも同じように動く」という過信です。
Node.jsのV8エンジンは確かにChromeと共通ですが、グローバルオブジェクトの差異(window vs global)や、TimerAPIの解像度、Promiseのスケジューリング実装には微妙な違いが存在します。
テストランナー選定と環境分離の原則
単体テストにはJestまたはVitestを選択するのが一般的ですが、この選択自体が互換性に影響を与えます。
VitestはViteを基盤としており、ESM(ECMAScript Module)をネイティブでサポートする一方、Jestは従来CommonJSとの親和性が高く、トランスフォーム設定が複雑になりがちです。
どちらを選ぶにしても、テスト実行環境を本番ブラウザ環境と完全に同一にはできないという前提を持ちます。
そこで実践的なアプローチとして、テスト対象を「ブラウザAPIに依存しない純粋ロジック」と「DOMやfetchを扱う境界コード」に分割します。
純粋ロジックはNode上で高速に回し、境界コードはモックに置き換える。
この分離ができていれば、単体テストの段階ではブラウザ差異をほぼ無視できます。
問題は、非同期処理が絡む境界コードのモック化にあります。
非同期処理のモックで失敗する3つのパターン
非同期関数をモックする際、以下の3つは特に落とし穴になりやすいです。
- タイマーの仮想時間操作:
jest.useFakeTimers()やvi.useFakeTimers()を使うと、setTimeoutやsetIntervalを仮想時間で進められますが、Promiseのマイクロタスクはこの仮想時間の影響を受けません。そのため、setTimeout(() => resolve(), 1000)のようなコードをテストするとき、jest.advanceTimersByTime(1000)だけでは不十分で、await Promise.resolve()を挟んでマイクロタスクキューを明示的にフラッシュする必要があります - fetchやXHRのモックライブラリ:
jest-fetch-mockやmsw(Mock Service Worker)は便利ですが、これらはブラウザの実際のCORSポリシーや、接続タイムアウト、リダイレクト動作を再現しません。例えば、fetchのkeepaliveオプションやAbortControllerの挙動は、ブラウザ実装に依存するため、モックでは検出できないバグを残します - イベントループの順序依存性:
Promise.thenとprocess.nextTick(Node固有)やqueueMicrotaskの混在は、ブラウザではqueueMicrotaskが標準ですが、Node環境ではprocess.nextTickが優先されるケースがあります。テストコード内でこれらのAPIを混用すると、ブラウザでは起きない順序でテストが成功し、本番でコールバックの実行順が逆転するという事態が起こります
仕様ベースのアサーション戦略
単体テストのアサーションは、「実装の詳細」ではなく「仕様が保証する振る舞い」 に対して行うべきです。
たとえば、配列のソート結果をテストするとき、ソートアルゴリズムの内部実装ではなく、Array.prototype.sortが返す配列の順序と元の配列が変更されるかどうかだけを確認します。
これにより、ブラウザがソートの安定性を変更しても、テストが不要に失敗することを防げます。
また、toStrictEqualやtoEqualのようなディープイコールは、オブジェクトのプロトタイプチェーンや列挙可能属性まで比較するため、ブラウザ間でSymbolプロパティの存在有無が異なる場合に誤検出を起こすことがあります。
その場合は、スナップショットテストを細かく分割し、変更頻度の低いコア部分だけに適用するのが現実的です。
テストカバレッジではなく動作保証の視点
カバレッジ率を追い求めるあまり、ブランチ網羅だけして仕様の曖昧な部分をテストしていないプロジェクトを多く見かけます。
特に、try-catch内でのエラー型の判別(instanceof TypeError vs instanceof DOMException)は、ブラウザによってスローされるエラーコンストラクタが異なるため、テストでは両方のパターンを網羅する必要があります。
これを実現するには、expectにカスタムマッチャーを追加し、エラー名やメッセージパターンで判定するロジックを抽象化しておくと、ブラウザ差異を吸収しやすくなります。
さらに、非同期テストのタイムアウト設定も盲点です。
デフォルトの5秒で十分なケースが多いですが、CI環境ではCPUリソースが制限されるため、実際よりも処理が遅延し、setTimeoutのコールバックが期待より遅れてテストが失敗することがあります。
こうしたフレークを減らすには、vi.setConfig({ testTimeout: 30000 })のように環境変数でタイムアウト値を調整し、かつbeforeEachでvi.clearAllMocks()を呼んで、前のテストの非同期処理が残らないようにする徹底した後処理が欠かせません。
単体テストでここまで配慮したとしても、DOM操作やレイアウトトリガーを含む処理は、やはり実ブラウザでの検証が必要です。
次のセクションでは、PlaywrightとWebDriverIOを使った統合テストフェーズに移り、単体テストでは見えなかったブラウザ固有のレンダリングやイベント伝播の問題にどう対処するかを解説します。
実ブラウザを使った統合テスト戦略 PlaywrightとWebDriverIOの使い分け

単体テストでロジックの正しさを検証した後、実際のブラウザ上で動作する統合テストは互換性問題の最終防衛線となります。
ここでは、DOM操作、イベント伝播、CSSレンダリング、ネットワークリクエストの実際の挙動を確認できます。
現在の主流ツールはPlaywrightとWebDriverIOですが、この2つは設計思想と得意領域が大きく異なります。
プロジェクトの特性に合わせて適切に使い分けることが、テストの保守性と検出能力を最大化する鍵です。
Playwrightの強みと適したユースケース
PlaywrightはMicrosoftが開発した比較的新しいフレームワークで、Chrome、Firefox、Safari(WebKit)の3エンジンをネイティブサポートしている点が最大の特徴です。
各エンジンに対して同じAPIで操作でき、かつ自動待機機能が充実しています。
具体的には、page.click()やpage.fill()が実行される前に、要素が可視状態になり、有効になり、安定するまで暗黙的に待機します。
これにより、従来のwaitFor地獄から解放され、テストコードが非常にシンプルになります。
Playwrightが特に有効なケースは、シングルページアプリケーション(SPA)や複雑な非同期レンダリングを含むモダンフロントエンドです。
例えば、ReactのuseEffectでデータをフェッチし、その結果でDOMが書き換わるようなフローでも、PlaywrightのwaitForSelectorやwaitForResponseを組み合わせれば、タイミング依存の不安定テスト(フレーク)を大幅に削減できます。
また、ネットワークモック機能が強力で、page.route()を用いて特定のAPIレスポンスを差し替えたり、遅延を注入したりできるため、エッジケースの再現が容易です。
さらに、Playwrightはトレースビューアというデバッグツールを内蔵しており、テスト実行中のスクリーンショット、DOMスナップショット、コンソールログ、ネットワークログを時系列で参照できます。
この機能は、ブラウザ間で動作が異なる場合の原因特定に非常に有効です。
WebDriverIOの強みと適したユースケース
一方、WebDriverIOはSelenium WebDriverプロトコルをベースにした長年の実績を持つフレームワークです。
W3C WebDriver標準に準拠しているため、モバイルブラウザ(Safari on iOS、Chrome on Android)や、Internet Explorer(レガシーサポート)を含む幅広い環境で動作します。
PlaywrightがIEをサポートしていないことを考えると、企業内システムや政府機関向けプロジェクトでは依然としてWebDriverIOが選択肢になります。
また、WebDriverIOはカスタムコマンドの拡張性が非常に高く、テストフレームワーク(Mocha、Jasmine、Cucumber)との統合も柔軟です。
さらに、Appiumとの連携により、ネイティブアプリやハイブリッドアプリのテストにも流用できるため、Webだけでなくモバイルも含めたクロスプラットフォーム戦略を取るチームには有利です。
ただし、WebDriverIOはPlaywrightと異なり、自動待機がデフォルトで有効ではないため、明示的なwaitUntilやpauseを多用することになります。
このため、テストコードにノイズが入りやすく、フレークの原因になりがちです。
その点は、waitForユーティリティをラップした社内ライブラリを整備することで補う必要があります。
ツール選定の判断基準と併用戦略
両ツールを比較する際の判断基準を、以下の表に整理しました。
| 評価軸 | Playwright | WebDriverIO |
|---|---|---|
| 対応ブラウザエンジン | Chromium, Firefox, WebKit | 全W3C準拠ブラウザ(IE含む) |
| 自動待機機能 | 強力(暗黙的かつ直感的) | なし(明示的記述が必要) |
| モバイルサポート | 限定的(デスクトップSafariのみ) | 充実(Appium経由) |
| デバッグツール | トレースビューア内蔵 | サードパーティ連携が必要 |
| 学習コスト | 低い(APIが一貫している) | 中程度(プロトコル知識が役立つ) |
| 並列実行性能 | 非常に高速(ワーカー単位) | 標準的(Selenium Grid連携) |
この表からわかるように、モダンなWebアプリで最新ブラウザのみをサポートするならPlaywright、レガシーブラウザやモバイルを含む広範なデバイスカバレッジが必要ならWebDriverIOが適しています。
ただし、両者を排他的に選ぶ必要はなく、単体テストはJest/Vitest、主要機能の回帰テストはPlaywright、そして特定のデバイス実機検証だけWebDriverIOというハイブリッド構成も現実的です。
実ブラウザテストで特に検証すべき項目
統合テストで必ずカバーすべき項目は、以下の通りです。
- イベントの伝播順序:
stopPropagation()やpreventDefault()がブラウザ間で同じ効果を持つか。特にタッチイベントとマウスイベントの混在する画面では、SafariとChromeで挙動が異なることがあります - CSSアニメーション完了後のDOM状態:
transitionendやanimationendイベントの発生タイミングはエンジンによって数フレームずれるため、テストでは一定のマージン(例:setTimeoutで50ms待機)を許容するアサーションを設計します - フォーム送信とリダイレクト:
submitイベント内でのfetchとlocation.hrefの組み合わせは、ブラウザによってはページ遷移前に非同期処理が完了しないケースがあるため、e.preventDefault()を強制するポリシーをテストに組み込みます - Web StorageとCookieのスコープ:
localStorageが同一オリジン内で正しく共有されるか、またsessionStorageがタブ単位で分離されるかを、ブラウザごとに検証します
これらの検証は、単なる存在確認ではなく、実際のユーザー操作フローをシミュレーションする形で実施します。
Playwrightの場合はtest.step()を使ってシナリオを構造化し、WebDriverIOの場合はbrowser.call()で非同期制御を明示するなど、ツールの機能を最大限に活用してください。
なお、統合テストは実行時間が長くなりがちです。
CIパイプラインでは、サポート対象ブラウザのうち主要な1〜2種だけをフル実行し、残りは週次バッチに回すといった段階的な戦略も検討に値します。
次のセクションでは、このテスト環境を構築する際の具体的なブラウザマトリクスとバージョン管理のプラクティスを解説します。
テスト環境の構築で押さえるべきブラウザマトリクスとバージョン管理

統合テストツールを選定した後、次に直面するのが「どのブラウザの、どのバージョンに対してテストを実行するか」という現実的な問題です。
すべてのブラウザの全バージョンを網羅することはリソース的に不可能であり、ビジネスインパクトと開発コストのバランスを取ったブラウザマトリクスの設計が求められます。
このマトリクスは単なるリストではなく、テスト環境の構築、CIパイプラインの並列化、さらにはバグ修正の優先順位にまで影響を与える戦略的な資産です。
サポート対象を決める3つの軸
ブラウザマトリクスを策定する際には、以下の3つの軸を定量的に評価します。
- 利用統計データ:Google Analyticsや社内のアクセスログから、実際のユーザーが使用しているブラウザとバージョンの分布を集計します。例えば、Chrome 80未満のシェアが0.5%未満であれば、そのバージョンをサポート対象外と判断できます。このデータは四半期ごとに見直し、トレンドの変化を追跡することが重要です
- プロダクトのターゲット市場:業務系システムではWindows + Edge/Chromeが大半を占める一方、クリエイティブツールではmacOS + Safariの割合が高まります。また、日本市場ではスマートフォンからのアクセスがデスクトップを上回るケースが多く、その場合はモバイルSafariとAndroid Chromeの検証が必須です
- 技術的負債の許容範囲:古いバージョンをサポートし続けるほど、ポリフィルの追加やトランスパイル設定の複雑化が進み、バンドルサイズや実行パフォーマンスに悪影響を及ぼします。サポート対象を「最新版 – 2バージョン」に絞ることで、モダンなAPI(Optional Chaining、Nullish Coalescing、Top-level await)をネイティブで使いやすくなります
マトリクスの具体例と優先順位付け
上記の軸を基に、実際のマトリクス例を示します。
ここでは、一般的なB2C Webアプリケーションを想定し、リリースブロッカーとなるCriticalレベルと、警告レベルでのモニタリング対象を分けて定義します。
| 優先度 | ブラウザ | バージョン範囲 | テスト実行タイミング | 失敗時の対応 |
|---|---|---|---|---|
| Critical | Chrome | 最新版 – 1 | すべてのPR | マージブロック |
| Critical | Firefox | 最新版 – 1 | すべてのPR | マージブロック |
| High | Safari | 最新版 – 1 | ナイトリービルド | リリース前に要修正 |
| Medium | Edge | 最新版 – 1 | 週次バッチ | 次バージョンで対応 |
| Low | Chrome Android | 最新版 | 月次バッチ | モニタリングのみ |
この表で重要なのは、Criticalとそれ以外でテスト実行頻度と失敗時の対応レベルを明確に区別している点です。
すべてのブラウザでPRごとにテストを実行すると、CIの待ち時間が増大し開発生産性を損ないます。
そこで、Criticalに絞って高速フィードバックを得て、その他は非同期に実行するという段階的アプローチが現実的です。
バージョン管理と定期アップデートの自動化
ブラウザは頻繁にアップデートされるため、マトリクスは静的なものではなく、定期的な見直しプロセスが必要です。
具体的には、以下の施策を実施します。
- ブラウザバージョンのピン留めを避ける:DockerイメージやCIランナーにインストールするブラウザを固定バージョンにすると、セキュリティパッチが適用されず、かつユーザー環境との乖離が生じます。Playwrightの場合は
playwright installで最新安定版を取得し、毎週のスケジュールジョブで自動更新する仕組みを組み込みます - バージョン廃止のアラート:Can I UseやMDNのブラウザサポート情報を定期的にクロールし、サポート対象外となったバージョンがマトリクスに含まれていないか監視します。GitHub Actionsであれば、
cronトリガーでこのチェックを実行し、Slackに通知するワークフローを構築できます - ベータ版・ナイトリー版での先行検証:メジャーアップデートのリリース前に、ブラウザのベータチャンネルでテストを実行し、事前に互換性問題を発見する仕組みも有効です。Playwrightでは
playwright install --with-deps betaでベータ版を導入できるため、これをCIの別ジョブとして設定します
エミュレーションと実機の使い分け
ブラウザマトリクスを構築する際、エミュレーション(デバイスモード)と実機テストの使い分けも戦略的に決める必要があります。
PlaywrightやChrome DevToolsが提供するデバイスエミュレーションは、ビューポートサイズやユーザーエージェントの偽装には優れていますが、タッチイベントの実際の感度や、バッテリー状態によるスロットリング、メモリ制約までは再現できません。
そのため、マトリクスに含めるデバイス種別については、以下の基準で分類します。
- エミュレーションで十分なケース:レイアウト崩れの検出、メディアクエリの動作確認、フォーム入力のバリデーションなど
- 実機が必要なケース:加速度センサーやジャイロスコープを用いた機能、ピンチイン・アウトのマルチタッチ、カメラやマイクのアクセス許可フロー
実機テストにはBrowserStackやSauce Labsのようなクラウドサービスを利用し、マトリクスで「Low」に分類したデバイスは実機ではなくエミュレーションで代替するなど、コストと精度のトレードオフを明確にします。
CI環境でのブラウザキャッシュと並列化の最適化
マトリクスが確定したら、CIパイプライン上で効率的に実行するための環境構築に移ります。
各ブラウザのバイナリはサイズが大きく(Chromeで約300MB、Firefoxで約200MB)、毎回ダウンロードするとビルド時間を浪費します。
そこで、キャッシュ戦略を導入し、~/.cache/ms-playwrightや~/Library/Caches/ms-playwrightをGitHub Actionsのキャッシュアクションで保存します。
ただし、ブラウザの自動更新とキャッシュのバージョン管理は競合するため、playwright installのハッシュ値をキャッシュキーに含めることで、更新時のみ再ダウンロードする仕組みが実践的です。
さらに、並列実行の粒度もマトリクスに連動させます。
Criticalブラウザは直列でも構いませんが、High以下のブラウザはshardオプションで分割し、複数のワーカーで分散実行します。
Playwrightの場合はplaywright test --shard=1/3のように指定でき、WebDriverIOでは--specオプションでファイル単位の分割が可能です。
これにより、全テストの実行時間を許容範囲(例えば10分以内)に収める調整ができます。
このように、ブラウザマトリクスとバージョン管理は、一度設定して終わりではなく、ユーザー動向やブラウザベンダーのロードマップに合わせて継続的に進化させるものです。
次のセクションでは、この環境で実行したテスト結果を基に、フィーチャーディテクションとポリフィルを組み合わせた安全なフォールバック実装について具体的に掘り下げます。
フィーチャーディテクションとポリフィルを組み合わせた安全なフォールバック実装

ブラウザマトリクスでサポート対象外と判定されたブラウザに対して、どのように動作を保証するか。
この問いに対する最も信頼性の高い回答が、フィーチャーディテクションとポリフィルの適切な組み合わせです。
ユーザーエージェント文字列を解析するブラウザスニッフィングは、偽装やバージョン表記の揺れで容易に破綻するため、実際にその機能が存在するかどうかを実行時に確認するフィーチャーディテクションが現代のスタンダードです。
ただし、ディテクションだけでは不足する機能を補うためにポリフィルを導入する際、パフォーマンスやバンドルサイズとのトレードオフを慎重に評価する必要があります。
ディテクションの実装パターンと危険な落とし穴
フィーチャーディテクションの基本形は、if (typeof Object.assign === 'function')のような存在確認です。
しかし、この単純なパターンには複数の罠が存在します。
第一に、プロパティ自体は存在しても、期待される振る舞いをしないケースです。
例えば、Symbol.iteratorはほとんどのモダンブラウザに実装されていますが、古いChromeでは存在しても配列の展開で正しく動作しないバグがありました。
この場合、'Symbol' in window && Symbol.iteratorだけでは不十分で、実際にArray.from([1,2][Symbol.iterator]())を試行するプローブ関数を実装する必要があります。
第二に、セキュリティコンテキストによる制限です。
navigator.credentialsやPaymentRequestは、HTTPSまたはlocalhostでのみ利用可能です。
HTTP環境でディテクションを行うと、undefinedが返るため機能がないと判断されますが、実際にはプロトコルが原因です。
このようなケースでは、ディテクションに加えてwindow.isSecureContextを同時にチェックし、エラーメッセージを開発者に明確に伝える設計が望ましいです。
第三に、メソッドのバインドコンテキストです。
document.createElementを変数に格納して呼び出すと、ブラウザによってはIllegal invocationエラーが発生します。
これは、ネイティブメソッドが正しいthisを要求するためです。
したがって、ディテクションコード内でFunction.prototype.callやReflect.applyを用いて、安全にテスト実行するラッパーを用意することが実践的です。
ポリフィル選定の基準と段階的読み込み戦略
機能が存在しないと判定された場合、ポリフィルを注入しますが、ここでの判断ミスがバンドルサイズ増大や実行時オーバーヘッドを招きます。
すべてのポリフィルを一括で読み込むのは避け、必要なものだけを条件付きで動的にインポートする戦略を取りましょう。
- core-jsは包括的なポリフィルライブラリですが、全体をインポートすると約80KB(gzip圧縮後)に達します。代わりに、
import 'core-js/features/array/at'のように個別モジュールを指定し、かつimport()を用いてディテクション成功時のみ読み込むことで、初期ロード時のペイロードを最小化します - ポリフィルサービスの利用:Polyfill.ioのようなCDNサービスは、ユーザーエージェントに基づいて必要なポリフィルのみを返却しますが、このアプローチは外部依存性とレイテンシが課題です。社内ポリシーでCDNが許可されている場合に限り、フォールバック用のセカンダリソースとして位置付けます
- ポリフィルとトランスパイルの重複に注意:Babelの
@babel/preset-envはuseBuiltIns: 'usage'オプションで、ソースコード内で使用されているAPIに基づいて自動的にポリフィルを注入します。しかし、この仕組みはNode環境でのバンドル時に静的解析を行うため、実行時の条件分岐でしか使われないAPIまでは検出できません。そのため、トランスパイル時のポリフィルと実行時ディテクションを二重に適用しないよう、エントリポイントでグローバルフラグを管理する仕組みが有効です
フォールバック実装の具体例とエッジケース対応
ディテクションに失敗した場合の代替動作(フォールバック)は、単にエラーを握りつぶすのではなく、ユーザー体験を段階的に低下させる設計が原則です。
例えば、IntersectionObserverが未サポートのブラウザでは、無限スクロールを「全件読み込みボタン」に置き換え、機能は劣化するものの完全に壊れない状態を維持します。
この実装では、ポリフィルではなくプログレッシブエンハンスメントの考え方を採用し、コア機能(データ表示と送信)はネイティブAPIに依存せず、拡張機能(アニメーションや遅延読み込み)だけをディテクション対象とします。
これにより、ポリフィルが読み込めないネットワーク障害時でも、最低限の操作は保証されます。
また、例外が発生した場合の再試行メカニズムも組み込んでおくと安心です。
ディテクション用のプローブ関数がtry-catchでラップされ、かつ初回失敗時にポリフィル読み込みをリトライするロジックを、setTimeoutでバックオフしながら実行することで、一過性のリソース読み込み失敗から復旧できます。
テストとの連携:ディテクションロジック自体を検証する
フィーチャーディテクションとポリフィルは、テストコードの対象外になりがちですが、このロジック自体がブラウザ間で正しく動作することを確認することが非常に重要です。
単体テストでは、navigator.__defineGetter__を用いて擬似的に機能の有無を切り替え、ディテクション分岐が想定通りに動くかを検証します。
ただし、この手法はグローバルオブジェクトを汚染するため、beforeEachとafterEachで必ず元の状態に復元する処理を徹底してください。
統合テストでは、Playwrightのpage.addInitScriptを使って、特定のブラウザ機能を意図的に無効化した状態でページを読み込み、フォールバックが正しくレンダリングされるかを確認します。
例えば、delete window.Promiseをスクリプトで注入してからページ遷移させれば、ポリフィルが注入されるべきシナリオを再現できます。
このように、フィーチャーディテクションとポリフィルは、単なる「おまじない」ではなく、テストによって継続的に品質保証されるべき第一級の機能です。
次のセクションでは、これらのテストをCI/CDパイプラインに組み込み、不安定テスト(フレーク)に対処する実践的な自動化手法を解説します。
CI/CDパイプラインでのテスト自動化と不安定テスト(フレーク)対策

ここまでの静的解析、単体テスト、統合テスト、ブラウザマトリクス、フィーチャーディテクションをすべて実装したとしても、CI/CDパイプライン上でそれらを安定的に実行できなければ意味がありません。
特に実ブラウザを用いたテストは、ネットワーク遅延、リソース競合、タイムアウト、さらにはCIランナーのCPUスロットリングなど、多種多様な要因で不安定(フレーク)になります。
フレークテストは開発者の信頼を損ない、結果的にテスト自体が無視される文化を生みます。
そこで、フレークを体系的に特定し、対策し、残存リスクを許容する仕組みをCI/CDに組み込むことが、長期的なテスト保守の成否を分けます。
フレークの原因分類と優先順位付け
フレークの原因は大きく4つのカテゴリに分類できます。
それぞれの特徴と対策の方向性を理解することが第一歩です。
- タイミング依存:
setTimeoutやrequestAnimationFrameのコールバックが、予想より遅れて実行される。または、非同期APIのレスポンスがテストのアサーションより後になる。これが最も一般的で、Playwrightの自動待機機能である程度吸収されますが、カスタムイベントやサードパーティスクリプトが絡むと再発します - 環境依存:CIランナーの負荷変動、共有キャッシュのヒット率、ブラウザのバージョンアップデートタイミング。特に、
playwright installで取得するブラウザバイナリが、前回の実行とマイナーバージョンで異なる場合、レンダリング結果が微妙に変わることがあります - テスト間の干渉:
localStorageやsessionStorage、グローバルなwindowオブジェクトのプロパティを、前のテストが変更したまま後片付けをしていない。また、fetchモックのリセット漏れで、意図しないレスポンスが後続テストに影響するケースも多いです - 外部サービス依存:テストで実際のAPIエンドポイントを呼び出している場合、ネットワーク障害やレート制限、レスポンスフォーマットの変更がフレークを誘発します
これらの原因に対して、優先的に対処すべきはタイミング依存とテスト間干渉です。
これらは内部で制御可能であり、修正によってフレーク率を劇的に低下させられます。
リトライ戦略とバックオフの設計
すべてのフレークをコード修正で防ぐことは現実的ではありません。
そこで、テスト実行自体にリトライ機構を組み込むことが実用的なソリューションです。
Playwrightではtest.describe.configure({ retries: 2 })のように、テストスイート単位でリトライ回数を設定できます。
WebDriverIOではmochaOpts.retriesやjasmineNodeOpts.retriesで同様の制御が可能です。
ただし、リトライは無条件に行うのではなく、失敗種別に応じて戦略を変更します。
例えば、アサーションエラー(期待値不一致)はリトライしても成功しないため、即座に失敗として報告します。
一方、タイムアウトエラーやElement not foundのようなセレクタ関連エラーは、リトライの対象とします。
この区別を実装するには、テストランナーのエラーハンドリングフックでエラーメッセージを解析し、リトライ可否を動的に判断するカスタムロジックが有効です。
また、リトライ間隔もバックオフ戦略に従わせます。
単純な固定待機ではなく、1回目は即時、2回目は500ms、3回目は2000msと指数関数的に間隔を広げることで、CIランナーの一時的な負荷上昇を回避できます。
このバックオフは、Playwrightのtest.beforeEach内でカスタムリトライラッパーを実装するか、p-retryのようなライブラリをテストヘルパーに組み込むことで実現します。
CIでの並列実行とリソース分離
フレークのもう一つの大きな要因は、複数テストワーカーが同一ブラウザインスタンスや同一ストレージを競合して使用することです。
これを防ぐには、テストスイートごとに独立したブラウザコンテキスト(Playwrightのbrowser.newContext())を生成し、テスト終了ごとにcontext.close()を確実に呼び出します。
さらに、storageStateをテスト間で共有しない、あるいは共有する場合は明示的にtestに依存しない固定状態を読み込む設計にします。
CIランナー上では、メモリとCPUの割り当てを監視し、ワーカー数を適切に調整することも重要です。
GitHub Actionsの標準ランナー(2コア、7GB RAM)であれば、Playwrightのワーカー数は4以上にすると逆にスワップが発生し、フレーク率が上昇するデータがあります。
そこで、workerCount = Math.min(4, os.cpus().length - 1)のような動的設定を導入し、ランナースペックに応じた調整を行います。
フレーク検出の自動モニタリング
リトライを導入しても、根本的にテストが不安定なまま放置されると、CIの実行時間が増大し、開発者の待機コストが無視できなくなります。
そこで、フレーク率を可視化するダッシュボードを構築し、各テストの過去10回の実行結果を集計します。
フレーク率が10%を超えるテストは自動的に「不安定テスト」としてラベル付けし、週次のレビュー会議で修正優先度を議論する仕組みを運用します。
具体的な実装例として、テストレポーターにカスタムリスナーを追加し、test.fail()とtest.pass()の履歴をデータベース(SQLiteやFirestore)に保存します。
その後、定期的なバッチジョブでフレーク率を計算し、Slackやメールでアラートを送信します。
これにより、「たまたま失敗しただけ」を「本当に直すべきバグ」と区別するカルチャーが醸成されます。
スモークテストとフルテストの分離
最終的に、CI/CDパイプラインでは、すべてのテストを毎回実行するのではなく、変更の影響範囲に応じて実行するテストセットを動的に選択する手法も有効です。
例えば、フロントエンドのUI変更がなければE2Eテストをスキップし、ユーティリティ関数の修正だけであれば単体テストのみ実行する、といった戦略です。
これは、changesetsやlernaの影響範囲解析と組み合わせることで実現できます。
また、リリース前の最終検証では、マトリクスに含まれる全ブラウザでフルテストを実行し、その結果が安定するまで自動リリースをブロックするゲートを設置します。
このゲートでは、フレークと判断されたテストを最大3回まで自動リトライし、それでも失敗した場合のみ人間の介入を促す仕組みとします。
これにより、開発者は日中のPRレビューでは高速なフィードバックを得て、夜間のバッチで網羅的な検証が完了するという、時間と品質のトレードオフを最適化できます。
次の最終セクションでは、これらの取り組みをすべて総合し、ブラウザ依存問題を完全にゼロにはできないが、制御可能な範囲に収めるための実践的テスト文化についてまとめます。
ブラウザ依存問題をゼロにはしないが制御可能にする 実践的テスト文化の確立

ここまで、静的解析から単体テスト、統合テスト、ブラウザマトリクス、フィーチャーディテクション、そしてCI/CD自動化まで、多層的なアプローチを体系的に解説してきました。
しかし、どれほど精巧なテスト戦略を敷いても、ブラウザ依存問題を完全にゼロにすることは現実的には不可能です。
新しいブラウザバージョンは常にリリースされ、仕様自体も進化し続けます。
重要なのは、「バグを出さないこと」ではなく、「バグが発生したときに、その影響範囲を最小化し、修正までの時間を短縮できる仕組み」を組織として持つことです。
これが、互換性問題と健全に付き合うための実践的テスト文化の核心です。
障害発生時のエスカレーションパスとログ設計
テストを通過したコードが本番で問題を起こしたとき、最初に頼りになるのは詳細な実行時ログです。
ブラウザ固有の動作差が原因の場合、navigator.userAgentだけでなく、機能ディテクションの結果や、ポリフィルが実際に読み込まれたかどうかのフラグをログに含めておくことが極めて有効です。
また、console.trace()を用いてスタックトレースを収集し、エラーが発生した関数の呼び出し元を特定できるようにします。
さらに、エラーレポートを単なるテキストで終わらせず、セッションリプレイツール(例:LogRocket、FullStory) と連携させることで、ユーザーの操作シーケンスを再現可能にします。
これにより、「どのブラウザの、どのバージョンで、どのような操作を行ったときに例外が発生したか」が可視化され、テスト環境では再現しなかったエッジケースにも迅速に対応できます。
テストの信用度を測るメトリクスと振り返りサイクル
テスト文化を成熟させるには、テスト自体の「質」を定量的に評価する仕組みが欠かせません。
以下のメトリクスを四半期ごとにレビューし、テスト戦略の改善点を抽出します。
- フレーク率:再実行で成功するテストの割合。5%を超える場合は、
waitForのタイムアウト値やモックの設計を見直すべきサインです - ブラウザ別障害検出率:各ブラウザでテストがどの程度の割合でバグを検出しているか。あるブラウザで極端に低い場合、そのブラウザ向けのテストシナリオが不足している可能性があります
- テスト実行時間のトレンド:CIパイプラインの経過時間がリリース頻度のボトルネックになっていないか。並列化やキャッシュ最適化の効果をモニタリングします
これらの数値は、ダッシュボードツール(GrafanaやDatadog)で可視化し、開発チーム全員が共有できる状態にします。
また、毎月の振り返り会議では、実際に本番で発生した互換性バグをケーススタディとして取り上げ、そのバグがどのテストフェーズで検出できるべきだったかを議論する「ポストモーテム」を実施します。
この文化が根づくと、テスト設計の暗黙知が形式知化され、新メンバーのオンボーディングにも役立ちます。
ドキュメントとしてのテストコードとナレッジベースの構築
テストコードは、単なる検証ツールではなく、「このコードがどのブラウザでどのように動くことを期待しているか」という設計ドキュメントとしての役割も担います。
そのため、テストケースのdescribeブロックやtestコメントには、以下の情報を明示的に記述することを推奨します。
- 対象とするブラウザとバージョン(例:
// Chrome 90+ and Firefox 88+) - 既知の非互換性とその回避策(例:
// Safari does not support RegExp lookbehind, using string split instead) - フォールバックが発動する条件とその際の期待動作
これに加えて、社内WikiやConfluenceに「ブラウザ互換性トラブルシューティングガイド」 を整備し、過去に発生した問題の解決手順や、ディテクションパターンのサンプルコードを蓄積します。
このナレッジベースは、テストコードだけでは伝えきれない「なぜそのテストが必要なのか」という背景を共有する場として機能します。
継続的改善のためのフィードバックループ
最後に、テスト文化は「静的」なものではなく、開発プロセスとともに進化する動的な存在です。
新たなブラウザバージョンがリリースされるたびに、サポートマトリクスを更新し、必要ならば新しいディテクションロジックを追加します。
また、開発者がローカル環境で気軽にブラウザテストを実行できる仕組み(例:npm run test:chromeのようなスクリプト)を提供することで、PR提出前に自己検証する習慣を醸成します。
このように、ブラウザ依存問題は「敵」ではなく、「制御可能なリスク」としてマネジメントする姿勢が、長期的なプロダクト品質と開発生産性の両立をもたらします。
完璧な互換性を追い求めるよりも、テストで守るべき箇所を明確にし、守れない箇所は監視と迅速な復旧でカバーする。
そのバランス感覚こそが、JavaScriptエンジニアとしての成熟度を表す指標であると、私は確信しています。
あなたのプロジェクトでも、ぜひこの多層的アプローチを導入し、ブラウザの違いに振り回されない、しなやかで強いテスト基盤を築いてください。


コメント