準拠性テスト¶
エンジンが「JavaScript として正しく動く」とは、突き詰めれば ECMAScript 仕様の定める振る舞いを再現できることです。しかし仕様は膨大で、条文を読んで実装しただけでは、細部の取りこぼしや解釈の誤りは避けられません。そこで頼りになるのが、仕様から直接導かれた大量のテストを集めた準拠性テストスイートです。この章では、その事実上の標準である test262 を題材に、スイートの構造・実行の仕組み・運用方針を扱い、あわせて準拠性テストが何を保証し何を保証しないのか、その限界を明らかにします。この限界の認識は、以降の章で扱う差分テスト・ファジング・メモリ安全性検出・ベンチマークといった他の品質保証手法が、なぜ別途必要になるのかの動機づけになります。
準拠性テストスイートとは¶
準拠性テストスイートとは、言語仕様が要求する振る舞いを一つひとつ検証する小さなテストプログラムの集合体です。ECMAScript には test262 という公式のスイートがあり、TC39(ECMAScript を策定する委員会)が仕様と歩調を合わせて保守しています。仕様に新しい機能が入れば対応するテストが追加され、既存の振る舞いにも網羅的なテストが用意されています。数万件規模のテストがあり、実装が標準に合致しているかを確かめるための第一の物差しになります。
重要なのは、これらのテストが仕様条文から直接導かれている点です。あるアルゴリズムのステップが「引数が負なら RangeError を投げる」と定めていれば、それを確かめるテストが存在します。つまりスイートを通すことは、仕様の各節を一つずつ確認していく作業に近く、自前のアドホックなテストでは見落としがちな隅々の分岐までカバーできます。逆に言えば、スイートに載っていない振る舞い(仕様が実装依存に委ねた部分や、そもそも仕様外の話題)は検証対象外です。
テストファイルの構造¶
test262 の各テストファイルは、実行すべきスクリプト本体と、その実行方法を指示するメタデータからなります。メタデータはファイル冒頭のコメントブロック(YAML 形式のフロントマター)に書かれ、ハーネスはこれを読んでテストの扱い方を決めます。主要なメタデータ項目は次のとおりです。
| 項目 | 意味 |
|---|---|
flags |
実行モードの指定(onlyStrict / noStrict / module / raw / async など) |
features |
テストが依存する言語機能名(未実装機能のテストを選別・除外するのに使う) |
includes |
事前に読み込むべきヘルパファイル(propertyHelper.js など) |
negative |
このテストがエラーで失敗すべきこと、およびその型と発生フェーズ |
description |
テストの目的を人間向けに述べた短い説明 |
esid / info |
対応する仕様上の識別子や補足情報 |
flags のうち特に扱いが重要なのが strict モードの指定です。JavaScript には strict モードと非 strict(sloppy)モードがあり、同じコードでも振る舞いが変わる箇所があります。onlyStrict は strict でのみ、noStrict は sloppy でのみ実行すべきことを表し、どちらの指定もない通常のテスト(raw や module を除く)は両方のモードで実行する必要があります。この場合、ハーネスは同じテスト本体を strict 版(先頭に "use strict"; を付す)と sloppy 版の二回に分けて走らせます。ここを取り違えると、片方のモードのバグを取りこぼします。
positive テストと negative テスト¶
テストは大きく二種類に分かれます。
- positive テスト: エラーを投げずに最後まで完走すれば合格。本体は
assert系ヘルパで期待値を検証し、条件を満たさなければ例外を投げて不合格を知らせます。 - negative テスト: 指定した種類のエラーが、指定したフェーズで発生すれば合格。
negativeメタデータにtype(SyntaxErrorやTypeErrorなど)とphaseが書かれます。
negative テストの phase は見落としやすい要点です。エラーが起きるべき段階として、少なくとも次が区別されます。
| phase | 意味 | 例 |
|---|---|---|
parse |
構文解析の段階で SyntaxError になるべき |
不正なトークン列、重複した仮引数(strict)など |
resolution |
モジュールの解決・リンク段階で失敗すべき | 存在しない名前の import など |
runtime |
実行時に指定のエラーを投げるべき | null のプロパティ参照による TypeError など |
parse フェーズのテストは、コードを実行する前にエラーにならなければなりません。もしエンジンが構文エラーを実行時まで遅らせて検出すると、たとえ最終的に同じ型のエラーを投げても不合格です。逆に、runtime フェーズのテストを解析時に早まって弾いてしまうのも誤りです。「いつ落ちるか」まで含めて仕様なのだ、という点が negative テストの肝になります。
非同期テスト¶
Promise やジョブキューが絡む振る舞いは、テスト本体が完了した時点では結果が確定していません。そこで async フラグ付きのテストは、専用のヘルパ(doneprintHandle.js など)が提供する完了通知の仕組みを使います。テストは検証が済んだら成功を、失敗すれば理由を通知し、ハーネスはその通知を待ってから合否を判定します。ここでハーネスがマイクロタスクを最後まで走らせ、通知を正しく待ち受けないと、非同期テストの結果を取りこぼしたり、偽の合格を出したりします。
実行の仕組み — ハーネス¶
スイートを走らせるのはハーネスと呼ばれる実行ドライバです。ハーネスの役割は、各テストについて次の手順を踏むことです。
- フロントマターを解析し、
flags/includes/negativeなどを取り出す。 - 共通ヘルパ(最低限
assert.jsとsta.js)と、includesで指定された追加ヘルパを読み込む。 flagsに従って実行モードを決める。strict/sloppy 両対応のテストなら二つの変種を生成する。moduleフラグならモジュールとして、rawフラグならヘルパを一切読まずに実行する。- テスト本体を実行する。
- positive なら「エラーなく完走したか」を、negative なら「指定の型のエラーが指定のフェーズで発生したか」を判定し、合否を記録する。
共通ヘルパのうち assert.js は assert.sameValue や assert.throws といった検証関数を、sta.js は失敗を表す Test262Error などの土台を提供します。テスト本体はこれらを前提に書かれているため、これらを読み込まずに本体だけ実行しても意味をなしません。raw フラグはこのヘルパ読み込みすら行わない特殊なモードで、ヘルパに依存できない極めて低レベルなテスト(そもそも assert を定義できるかどうかを試すような場合)に使われます。
判定ロジックは種別ごとに単純化すると次のようになります。
run(test):
if test は negative:
try 実行(test)
期待どおりのフェーズで期待の型のエラーが出たか? → 出れば pass / 出なければ fail
else: # positive
try 実行(test)
エラーなく完走したか? → 完走で pass / 例外で fail
negative テストで「フェーズ」を正しく判定するには、ハーネスがエラーの発生段階を区別できなければなりません。たとえば parse フェーズなら、本体を実行に回す前に一度コンパイル(構文解析)だけを試み、その段階でエラーが出たかを確認する、といった作り分けが要ります。
サブセット実行と full 実行¶
数万件のテストをすべて走らせる full 実行は、実装が仕様にどれだけ合致しているかを示す最終的な基準(グラウンドトゥルース)です。しかし件数が多く、strict/sloppy の二重実行も相まって、一回に相応の時間がかかります。開発のたびに毎回 full を回していては、変更のフィードバックが遅くなりすぎます。
そこで実務では、変更に関係する範囲だけを走らせるサブセット実行を主体にします。正規表現エンジンを直したなら正規表現関連のディレクトリだけ、Array.prototype のメソッドを変えたならその周辺だけ、というように絞れば、数秒から数十秒で結果が返り、素早く反復できます。test262 はディレクトリ構造が仕様の章立てにおおむね対応しており、features メタデータでも機能ごとに選別できるため、関連サブセットの切り出しは比較的容易です。
両者の使い分けは次のように整理できます。
| 観点 | サブセット実行 | full 実行 |
|---|---|---|
| 目的 | 変更箇所の素早い検証 | 準拠状況の確定的な確認 |
| 速度 | 速い(秒〜十数秒) | 遅い(分単位以上) |
| カバー範囲 | 変更に関係する範囲のみ | 全件 |
| 回す頻度 | 改修のたび | 節目・指示があったとき |
| 弱点 | 範囲外の予期しない退行を見逃す | 遅くて反復に向かない |
サブセット実行の弱点は、変更が思わぬ遠くの機能に波及した退行を見逃すことです。共有コード(値表現・GC・オブジェクトモデルなど基盤に近い部分)に触れたときほど影響範囲は読みにくくなります。したがって運用上は、「日々の改修ではサブセットで素早く回し、リリースやマイルストーンなど区切りの局面、あるいは基盤に触れて影響範囲が読めないときに full を回す」という方針が現実的です。full をいつ回すかを規律として決めておくと、速度と網羅性のバランスを保てます。
スキップと非目標の扱い¶
準拠性テストの合否を語るうえで避けて通れないのが、実装しないと決めた機能のテストをどう扱うかです。エンジンには、設計上あえて実装しない機能 — 明文化された非目標(non-goal)— がありえます。軽量さを優先して国際化 API(ECMA-402)を持たない、といった判断がその例です。これらの機能に対応するテストは、実装がない以上どうやっても通りません。
ここで大切なのは、非目標のテストを失敗として数えず、スキップとして扱うことです。失敗に数えてしまうと、「意図的に作らないと決めたもの」と「作ったつもりが壊れているもの」が同じ数字に混ざり、準拠率が実力を正しく反映しなくなります。非目標を除いた母集団に対する合格状況こそが、エンジンが「やると決めた範囲」でどれだけ仕様に忠実かを表します。多くのテストは features メタデータで機能名が付いているため、非目標の機能名を持つテストを機械的に除外リスト(スキップリスト)へ入れられます。
ただしスキップには重大な落とし穴があります。スキップリストは容易に嘘をつくという点です。
実装上の罠・知見¶
100% 準拠は「正しい」でも「安全」でもない¶
もっとも戒めるべき誤解が、「準拠性テストが全件通れば、そのエンジンは正しく安全だ」という思い込みです。準拠性テストが確かめるのは、妥当な入力に対する仕様どおりの振る舞いであって、それ以上ではありません。次のような欠陥は、スイートが 100% 通っていても平然と潜みます。
- メモリ安全性のバグ: 第III部「ネイティブコードとの相互作用と GC 安全性」で見た use-after-free は、GC が「ちょうど危険な瞬間」に走ったときにだけ顕在化します。通常の実行ではその確率が低いため、テストは回収を誘発せずに通過してしまいます。原因と症状が離れるパターンではなおさらで、100% 通るスイートがこの種のバグをまったく検出しないことは珍しくありません。これを確定的に炙り出すには GC ストレスと解放オブジェクトのポイズニングが要り、それは準拠性テストとは別の仕組みです(第VI部「メモリ安全性の検出」)。
- 性能の退行: 準拠性テストは「正しい答えを返すか」だけを見て、「どれだけ速く・省メモリで返すか」は問いません。ある改修で計算量が O(n) から O(n²) に悪化しても、答えさえ合っていれば全件通ります。実際、走査対象を毎回再解析する実装や、必要のない副作用追跡を常時実行する実装は、正しい結果を出しながら二次的な計算量に陥ることがあります。これを捉えるにはベンチマークと計算量スケールの確認が必要です(第VI部「ベンチマーク」)。
- 未定義・実装依存の振る舞い: 仕様が結果を規定していない部分(列挙順の一部、実装依存の上限など)は、テストも結果を固定しません。ここでの妥当性は準拠性テストの外にあります。
- 意味の取り違え: そもそも test262 がカバーしていない組み合わせや入力域では、バグがあってもテストが存在しないため見えません。網羅は広大でも完全ではありません。これを補うのが、参照実装や別エンジンと突き合わせる差分テストです(第VI部「差分テスト」)。
要するに、準拠性テストは品質保証の必要条件ではあるが十分条件ではない。全件合格は出発点であって到達点ではありません。この認識が、以降の章で複数の異なる検出手法を積み重ねる理由になります。
スキップリストを正直に保つ¶
スキップの仕組みは、非目標を正しく除外するためのものです。しかし同じ仕組みは、通らないテストを「非目標」と偽って隠すのにも使えてしまいます。実装のバグでたまたま落ちるテストを、面倒だからとスキップリストに放り込めば、準拠率の数字は何事もなかったかのように 100% を保ちます。これはもっとも避けるべき自己欺瞞です。
規律は単純です。スキップしてよいのは、明文化された真の非目標のテストだけであること。落ちる原因が自エンジンのバグなら、それはスキップではなく修正すべき失敗です。この線引きを守るために、次のような運用が有効です。
- スキップの理由を「どの非目標に対応するか」まで含めて記録し、バグ由来の除外と峻別する。
- 非目標の判断は文書化された方針に紐づけ、個々のテストの都合で増やさない。
- スキップリストを定期的に棚卸しし、「いつの間にか増えた原因不明のスキップ」がないかを点検する。
スキップリストは準拠率という数字の分母を決める強力なつまみです。だからこそ、それを触る規律の緩さが、そのまま準拠率という指標の信頼性を左右します。数字を良く見せるためにつまみを回した瞬間、その数字は品質の指標として死にます。
strict/sloppy の二重実行を省かない¶
前述のとおり、strict 指定のないテストは両モードで走らせるのが正しい扱いです。ハーネスの実装を簡略化して片方(たとえば sloppy 版)しか走らせないと、strict モード特有の振る舞い — 代入不可プロパティへの代入が黙って無視されるか TypeError になるか、this が undefined になるかグローバルになるか、といった差 — のバグをまるごと取りこぼします。件数は倍になりますが、ここを省くと準拠性テストの網羅性そのものが崩れます。
「通った」の判定を甘くしない¶
positive テストで「例外を投げずに終わった」ことだけを見て合格にするのは正しいのですが、非同期テストではマイクロタスクを最後まで走らせて完了通知を待つ必要があります。ここを待たずに同期的な完了だけで合格判定すると、Promise 絡みの失敗を偽陽性(実際は失敗なのに合格扱い)で通してしまいます。negative テストでは、前述のフェーズ判定を省いて「最終的に同じ型のエラーが出たか」だけで見ると、構文エラーを実行時まで遅らせるバグを見逃します。合否判定のロジックそのものにバグがあると、スイート全体の信頼性が底から崩れる点に注意が要ります。
まとめ¶
- 準拠性テストスイート(ECMAScript では test262)は、仕様条文から直接導かれた大量のテスト群であり、実装が標準に合致しているかを測る第一の物差しである。
- 各テストはフロントマターのメタデータ(
flags/features/includes/negativeなど)で扱い方を指示し、positive(完走で合格)と negative(指定フェーズで指定エラーが出れば合格)に大別される。非同期テストは完了通知を待って判定する。 - ハーネスはヘルパを読み込み、strict/sloppy の変種を作り分け、種別ごとに合否を判定する。strict 指定のないテストは両モードで走らせる。
- full 実行はグラウンドトゥルースだが遅く、日々はサブセットで素早く回し、節目や影響範囲が読めないときに full を回すのが現実的な運用である。
- 非目標の機能のテストは失敗ではなくスキップとして扱う。ただしスキップリストは容易に嘘をつくため、除外してよいのは真の非目標だけに限り、正直さを規律で保つ必要がある。
- 準拠性テストは妥当な入力への仕様どおりの振る舞いを確かめるが、メモリ安全性のバグや性能の退行は捉えない。100% 合格は必要条件であって十分条件ではなく、差分テスト・ファジング・メモリ安全性検出・ベンチマークといった他の手法を積み重ねる動機になる。
参考文献¶
- test262 (ECMAScript 公式テストスイート) — スイート本体とディレクトリ構造・ヘルパの一次資料。
- test262 INTERPRETING.md — メタデータの意味・flags・negative フェーズ・非同期テストの扱いを定めた一次資料。
- ECMA-262 (ECMAScript 言語仕様) — テストが検証する振る舞いの根拠となる仕様条文。