差分テスト¶
準拠性テスト(前章)は「仕様がこう定めた入力に対し、こう振る舞うべき」という固定された期待値の集合との突合でした。しかし、どれほど大規模なスイートでも、想定された入力の組み合わせしか検証できません。実際のバグは、個々の機能が単体では正しいのに、特定の組み合わせでだけ壊れる、という形で潜んでいることが多くあります。差分テストは、期待値をあらかじめ用意する代わりに、信頼できる別の実装(参照実装)を第二の判定基準(オラクル)として使い、同じプログラムを両者で走らせて出力を比較します。食い違いが出れば、そのどちらかにバグがある候補です。この章では、何を比較するか、テストするプログラムをどこから調達するか、そして食い違いをどう切り分けるかを扱います。
差分テストの考え方¶
準拠性テストが機能するのは、各テストに「正解」があらかじめ埋め込まれているからです。裏を返すと、テストの著者が思いつかなかった入力や、複数機能が絡み合う経路には正解が存在せず、そこにあるバグはすり抜けます。とりわけ軽微でないのが、単体では正しい操作どうしが組み合わさったときにだけ現れる誤りです。ある配列メソッドは通常の配列に対しては正しいが、特定の作られ方をした配列に対しては誤る。ある算術演算は普通の値では正しいが、ライブラリのアルゴリズムを通したときにだけ到達するエッジケースで誤る。こうした誤りは、それを踏む具体的な入力が準拠性スイートに含まれていなければ、100% パスの状態でも残り続けます。
差分テストは、正解を人手で用意することを諦め、代わりに別の成熟した実装の出力を正解の代理とします。同じプログラム P を自分のエンジンと参照実装で走らせ、両者の出力が一致すれば「少なくとも両者は同じ結論に達した」、食い違えば「どちらかが間違っている(たいていは自分の側)」と判断します。期待値を書く必要がないため、入力さえ用意できれば無限に検証できるのが最大の利点です。これは、機械生成したプログラム(次章「ファジング」の主題)や、後述する実世界のライブラリと組み合わせたときに絶大な威力を発揮します。
flowchart LR
P["同一プログラム P"] --> E1["自エンジン"]
P --> E2["参照実装(オラクル)"]
E1 --> N1["正規化 → チェックサム"]
E2 --> N2["正規化 → チェックサム"]
N1 --> CMP["チェックサムを比較"]
N2 --> CMP
CMP -. "一致" .-> OK["両者が同じ結論に到達"]
CMP -. "不一致" .-> NG["相違 = バグ候補"]
同一プログラムを自エンジンと参照実装の双方で走らせ、正規化したチェックサムの一致で「同じ答えに達したこと」を、不一致でバグ候補を検出する。
基本的な発想 — 同一入力を複数の実装に与え、出力の相違をバグの兆候とみなす — は言語処理系に限らず広く応用できますが、JavaScript エンジンのように仕様(ECMA-262)を共有する成熟した実装が複数存在する領域では、とりわけ相性が良い手法です。
準拠性テストと差分テストの守備範囲¶
両者は競合ではなく補完関係にあります。それぞれが得意とする領域を整理すると次のようになります。
| 観点 | 準拠性テスト | 差分テスト |
|---|---|---|
| 正解の出所 | テストに埋め込まれた固定の期待値 | 参照実装の出力(オラクル) |
| 検出が得意なもの | 仕様条文の単体的な充足漏れ | 機能の組み合わせで生じる意味バグ |
| 入力の網羅 | スイート著者が想定した範囲 | 生成・実ライブラリで想定外まで広げられる |
| 期待値の用意 | テストごとに必要 | 不要(参照実装が代わる) |
| 判定の確実さ | 仕様準拠を直接主張できる | 相違の指摘のみ(どちらが正かは別途切り分け) |
| 弱点 | 想定外の入力・組み合わせを見逃す | 参照実装が誤っていると誤検出になる |
| カバレッジの測り方 | パスしたテスト数 | 走らせたプログラムの多様性 |
準拠性スイートは「仕様に準拠している」という主張の直接的な裏付けになりますが、その主張はテストされた入力に限られます。差分テストは仕様準拠を直接は主張できない代わりに、期待値の壁を越えて未知の入力へ検証を広げられます。両者を併用してはじめて、「既知の要件を満たし、かつ既知でない組み合わせでも他実装と食い違わない」という二重の保証に近づきます。
何を比較するか¶
同じプログラムを二つのエンジンで走らせたとき、比較対象になるのは典型的には次のいずれかです。
- 標準出力(印字結果): プログラムが
print相当で出力した文字列を丸ごと比較する。もっとも単純で、対象エンジンに共通の出力手段さえあれば成立する。 - 結果の正規化シリアライズ: プログラムの最終的な値(オブジェクトや配列)を、順序や表記を安定させた形に直列化して比較する。
JSON.stringifyの結果や、キーをソートした列挙などがこれにあたる。浮動小数点の表記ゆれや列挙順の実装差を吸収するため、比較前の正規化が肝心。
いずれの場合も、生の出力そのものを毎回突き合わせるとコストがかさみます。実務的には、各プログラムの出力から安定したチェックサム(ハッシュ値)を計算し、それを比較するのが有効です。チェックサムには二つの役目があります。第一に、相違の検出が安価になる — 長い出力どうしをバイト比較する代わりに固定長のハッシュ一つを比べればよい。第二に、これが見落とされがちですが、すべてのエンジンが同じものを計算したことを確認できる。単に「エラーを出さずに完走した」だけでは不十分で、完走したうえで同じ答えに達したことをチェックサムの一致が保証します。
比較を成立させるうえでの落とし穴は、本質的でない差を相違として拾ってしまうことです。エラーメッセージの文言、スタックトレースの形式、オブジェクトの列挙順のうち仕様が規定しない部分、浮動小数点の最短表記 — これらは実装ごとに正当に異なりえます。比較の前にこうした非本質的な差を正規化して取り除いておかないと、真のバグが大量のノイズに埋もれます。何を「同じ」とみなすかの定義そのものが、差分テストの設計の中心です。
テストプログラムの調達¶
差分テストは入力さえあれば無限に回せますが、その入力の質が結果を決めます。調達元は大きく三つあります。
手書きのテスト群¶
過去に踏んだバグや、仕様の込み入った箇所を狙って人手で書いたプログラム群です。数は稼げませんが、意図を持って際どい組み合わせを突けるため、密度の高い検証になります。修正したバグの再発防止(回帰)としても機能します。
生成したプログラム¶
文法に基づいて機械的にプログラムを合成する方法です。人間が思いつかない組み合わせに到達できるのが強みで、量で押せます。これは次章「ファジング」の主題そのものであり、差分テストと生成は密接に結びつきます — ファザが生み出した大量のプログラムに対し、参照実装との出力比較が「そのプログラムの正解」を与えるオラクルの役目を果たすからです。生成とオラクルは、片方だけでは機能しにくい両輪の関係にあります。
実世界のサードパーティライブラリ¶
もっとも強力な調達元が、それ自身が自己検証のテストスイートを持つ実在のライブラリです。多くの成熟した純 JavaScript ライブラリは、「この入力に対し結果はこうなるはず」というアサーションの塊であるテストスイートを同梱しています。このスイートを自分のエンジンで走らせると、ライブラリのテスト自体がオラクルとして働きます。参照実装(あるいはライブラリ作者の CI)ではそのテストが通るのに、自分のエンジンで走らせると失敗する — このとき、失敗した箇所は自分のエンジンのバグの強い候補です。
この方法が効くのは、実ライブラリが現実的で入り組んだコードパスを踏むからです。日付計算ライブラリ、大きな数を扱う演算ライブラリ、パーサ、ステートマシン — これらは単純なマイクロテストでは決して到達しない、機能の深い組み合わせを日常的に通過します。ライブラリのアルゴリズムが内部で作り出す「特定の形に育った配列」や「特定の経路でのみ現れる数値のエッジ」に自分のエンジンが正しく応答できるかが、実使用に近い形で試されます。
ただし制約があります。
- 純 JavaScript であること: 対象ライブラリは、自分のエンジンが持たないホスト依存(ファイルシステム、ネットワーク、DOM、Node 固有 API など)を持たない純粋な JS でなければならない。言語機能だけで完結するライブラリが理想的。
- 少数のホストグローバルの補完(shim)が要ることがある: ライブラリの読み込みに、
console・タイマー・モジュールの読み込み機構といった、言語仕様外だが慣習的に存在するグローバルを最小限だけ用意する必要が生じる場合がある。これらを薄く埋める(shim する)ことで、本来テストしたい言語機能の部分だけを走らせられる。
食い違いの切り分け¶
食い違いが一件出たとき、そのままではバグ報告にも修正にも使えません。生の食い違いは「巨大なライブラリのどこかで結果が違った」程度の粒度でしかないからです。使える情報にするには、最小化と帰属の二段階が要ります。
最小化は、食い違いを再現する最小のプログラムまで削り込む作業です。実ライブラリで見つかった相違なら、ライブラリのコードを少しずつ取り除き、相違が消えない範囲で切り詰めていき、最終的にホスト依存を含まない純粋な言語機能だけの小さな断片に還元します。理想は、数行で「この式が、自分のエンジンでは X、参照実装では Y」と言い切れる形です。小さな再現があってはじめて、原因箇所の特定も、他者への報告も、回帰テストへの追加も可能になります。最小化は次章のファジングでも中心的な工程であり、そこでは自動最小化の手法として詳述します。
帰属は、「そもそもどちらが正しいのか」を仕様に照らして決める作業です。食い違いは常に自分のエンジンのバグを意味するわけではありません。切り分けの結論は次の三通りになりえます。
- 自分のエンジンのバグ: 仕様に照らして参照実装が正しく、自分の出力が誤っている。もっとも多いケースで、修正対象。
- 参照実装が非準拠: 参照実装の側が仕様に反している。成熟した実装でも、仕様の曖昧な隅や比較的新しい機能では起こりうる。この場合は自分の側を変えてはならず、相違を記録して除外する。
- 意図的な非目標との相違: 自分のエンジンが仕様の一部を意図的に実装しない方針(たとえば国際化など)を採っている場合、その領域での相違は「バグ」ではなく設計上の既知の差。これも除外対象として明示的に管理する。
帰属を誤ると、参照実装のバグに自分を合わせてしまう(仕様から遠ざかる)、あるいは自分の正しい非目標を「直そう」として労力を無駄にする、といった逆効果を招きます。最終的な正否の基準は常に参照実装ではなく仕様(ECMA-262)であり、参照実装はあくまで「相違に注意を向けさせる装置」だと位置づけるのが健全です。
実装上の罠・知見¶
参照実装も間違いうる¶
差分テストで最初に染みつけるべき心構えは、「参照実装は正解そのものではなく、正解の代理にすぎない」ことです。成熟したエンジンでも、仕様の曖昧な箇所、実装依存(implementation-defined)と規定された挙動、新しい機能の初期実装では、仕様と食い違うことがあります。相違を機械的に「自分のバグ」と決めつけると、正しい実装をわざわざ壊しかねません。相違の正否は必ず仕様に立ち返って判定し、参照実装が誤っている件は除外リストとして記録しておくことが重要です。可能なら複数の参照実装と突き合わせ、多数決の形にすると、単一実装の癖に引きずられにくくなります。
意図的な非目標を相違として扱わない¶
エンジンが仕様の一部を意図的に実装しない方針を採っている場合、その領域では参照実装と当然に食い違います。これはバグではなく設計判断であり、差分テストの基盤に「既知の意図的相違」の一覧を持たせ、そこに該当する相違は自動的に除外する仕組みが要ります。この一覧を整備しないと、非目標領域から出続けるノイズに真のバグが埋もれ、差分テスト全体が形骸化します。
最小化しない相違は資産にならない¶
「大きなライブラリのどこかで結果が違った」という生の情報は、そのままでは修正にも報告にも使えません。純粋な言語機能だけの小さな再現に還元してはじめて、原因の特定・仕様との照合・回帰テスト化が可能になります。最小化を後回しにすると、再現手順が失われて相違そのものが揮発することもあります。相違を見つけたら、まずホスト依存を剥がして最小の言語断片に落とす、を定石にすべきです。
100% パスのスイートが見逃すもの¶
もっとも実務的な知見は、準拠性スイートを 100% 通過してもなお、実ライブラリや別エンジンとの差分テストがバグを見つけることです。準拠性テストは個々の機能を単体で検証しますが、実際のバグはしばしば機能の組み合わせに宿ります。ある配列メソッドが、通常の手順で作られた配列には正しく働くのに、ライブラリのアルゴリズムが特定の経路で育てた配列に対しては誤る。ある算術演算が、単純な値では正しいのに、ライブラリの計算過程でだけ到達する境界値で誤る。これらの誤りに対応する具体的な入力が準拠性スイートに含まれていなければ、スイートは緑のまま、バグは残ります。差分テストの価値は、この「単体では正しいが組み合わせで壊れる」層を、期待値を人手で用意することなく掘り当てられる点にあります。
まとめ¶
- 差分テストは、固定の期待値の代わりに信頼できる参照実装を第二のオラクルとして使い、同じプログラムの出力を比較して相違をバグの候補とみなす手法である。
- 比較対象は印字結果か結果の正規化シリアライズで、安定したチェックサムを用いると相違を安価に検出でき、かつ全エンジンが同じ答えに達したことも確認できる。
- テストプログラムは、手書きの試験群・機械生成(ファジングと直結)・実世界のライブラリの自己検証スイートから調達する。純 JS であることと、少数のホストグローバルの補完が要る点が制約。
- 相違は最小の純粋な言語断片へ最小化し、仕様に照らして「自分のバグ/参照実装の非準拠/意図的な非目標」のいずれかへ帰属させる。正否の最終基準は参照実装ではなく仕様である。
- 準拠性スイートを 100% 通過しても、実ライブラリや別エンジンとの差分テストは、機能の組み合わせでだけ壊れる意味バグを見つけ出す。準拠性テストと差分テストは補完関係にある。
参考文献¶
- ECMA-262 (ECMAScript 言語仕様) — 相違の正否を判定する最終的な基準となる共有仕様。参照実装と自エンジンがともに準拠を目指す先。