全値相異(Distinct Values)テストとは、テスト入力データに含まれるすべてのフィールドや引数に互いに異なる値を割り当て、データの対称性による欠陥見逃しを防ぐテスト設計手法です。

通常であればフロントエンドには TypeScript を選ぶところですが、AI によるコーディングを前提とするならば、ゼロからコードを組み立てるコストは大きく下がるため、初期作成の負担はそれほど気になりません。むしろフロントエンドとバックエンドを同じ言語で書ける利点を優先し、フロントエンド側にも Rust を採用しました。かつてフルスタック単一言語といえば JavaScript や Node.js が主流でしたが、現在では Rust でも同様に揃えられる時代になっています。

しかし、WebAssembly(Wasm)境界においてバイナリサイズ削減のために約37個の位置プレースホルダを用いて手書き展開した JSON(ある都市シミュレーションの Wasm クレートにおける状態取得関数)に対し、出力全体をパースして構造体と丸ごと照合するテストを書いたものの、レビューの変異テストで思わぬ死角が露呈しました。テスト入力データ内で値が重複している項目同士を入れ替えても、テストが全件 PASS してしまったのです。

人手による値調整の限界を痛感した私は、テスト入力を「全値相異」に設計し直し、さらにテスト自身が重複のないことを機械的に検証する自己 assert(メタアサーション)を導入しました。本記事では、出力全体を照合するテストでもフィールド取り違えを見逃す原理と、その対策について実測ログをもとに論じます。

なぜ「全体パース照合」でも取り違えを見逃すのか:実務での選択と盲点

37個の位置プレースホルダと「似た項目に別々の値を入れる」という油断

言語境界(Wasm や FFI)において、Rust 側の複雑な構造体を外部へ渡す手法にはいくつかの選択肢があります。プロトタイプ初期や軽量化が最優先される場面では、外部シリアライザへの依存を避け、format! マクロと位置プレースホルダを用いて手動で JSON 文字列を組み立てる実装が選ばれることがあります。

私たちが開発していた都市シミュレーションの Wasm クレートでも、バイナリサイズを最小限に抑えるため、約37個の {} プレースホルダを並べた手書きの JSON フォーマットを採用していました。

引用元: プロジェクトA(非公開)の lib.rs

        f.agriculture,
        t.smithy,

このような手書き JSON は、引数の位置を1つでも間違えると出力 JSON のキーと値の対応が狂ってしまいます。そこで、初期の単体テストでは出力全体を serde_json::Value にパースし、期待値の JSON 構造と丸ごと assert_eq! で比較するテストを作成しました。

このテストを書いた開発者も、値の重複が危険であることは認識していました。そのため、テストコード内には以下のようなコメントが残されていました。

引用元: プロジェクトA(非公開)の lib.rs

// 値が対称だと項目の取り違えを検出できないので、似た項目に別々の値を入れる。
c.population.general.child_female += 3;
c.population.general.elder_male += 5;
for (industry, rate_permille) in [(IndustryType::Agriculture, 110), (IndustryType::Tavern, 230)] {
    enact(&mut c, Policy::SetTaxRate { industry, rate_permille }).unwrap();
}

男女比や特定の税率を手動でずらし、「似た項目に別々の値を入れた」ことで、開発者は十分なテストケースを用意できたと考えました。前方一致のような部分検証ではなく、出力 JSON 全体をパースしてキーの構造まで完全一致を確かめているのだから、フィールドの漏れや取り違えは確実に防げるという安心感があったのです。

レビューの変異テストが突いた2組の死角

しかし、その安心感はコードレビューで実施された変異テスト(Mutation Testing)によって打ち砕かれました。

レビューを担当した Spec Review サブエージェントが、実装コードに対して引数を入れ替える変異(スワップ変異)を注入してテストを実行したところ、意外な結果が返ってきました。水道施設数(90)と衛生施設数(80)の入れ替えはテスト失敗として正しく検出できたものの、以下の2組の入れ替えはテストが全件 PASS して見逃されてしまった のです。

  1. 収容能力と住宅規模(cap.capacity と cap.housing、どちらも 700)
  2. 農業施設数と鍛冶屋税率(f.agriculture と t.smithy、どちらも 100)

引用元: hands-on-log.md §1

    s_mut = s.replace("        f.agriculture,\n", "@@@").replace("        t.smithy,\n", "        f.agriculture,\n").replace("@@@", "        t.smithy,\n")
test result: ok. 5 passed

なぜ出力全体をパースして照合しているテストが、明らかな実装バグを見逃してしまうのでしょうか。

原因は、テスト入力データ内における 値の偶発的な一致(データの対称性) にあります。テスト入力において農業施設数が 100 であり、鍛冶屋税率の初期値も偶然 100‰ であった場合、手書き JSON 内でプレースホルダの引数をどれだけ入れ替えても、出力される JSON 文字列中の該当フィールドの値はどちらも 100 のまま変わりません。結果として、期待値との照合は何事もなく成功してしまいます。

変異テストの理論において、コードに変更を加えてもテストケース内限定で意味論的に同一の結果を生んでしまう変異体を等価変異体(equivalent mutations)と呼びます。Rust 向けの変異テストツール cargo-mutants の用語を用いれば、テストが欠陥を検出できずにミュータントが生存してしまった(missed)状態です12。

API テストの分野では、レスポンス全体をキャプチャして比較するスナップショットテストが「すべてを捕獲できる(Catching everything)」網羅的な手段として語られることがあります3。Jest などのツールでもスナップショット照合は広く普及しています4。しかし、スナップショットテストが保証できるのは「記録されたベースラインと現在の出力が一致していること」だけであり、テスト入力データ自身が抱える対称性の死角までは暴いてくれません。入力値が重なっている限り、どれほど厳密に出力全体を照合していても、フィールドの取り違えは原理的にすり抜けてしまうのです。

メリットの裏にあるトレードオフと適用限界

手動調整を阻む「連鎖的衝突(Cascading Collisions)」の実態

レビューでの指摘を受けた私たちは、即座に入力データの調整に着手しました。指示の内容は明確でした。「テストデータ内の全値が互いに異なるように調整し、全値が互いに異なっていることをテスト内で assert せよ」というものです。

そこで、テストの末尾に出力 JSON の数値を再帰走査して、重複した値が見つかったら即座に panic する重複アサーションを追加しました。ところが、そのアサーションを有効化した途端、テストは次々と panic を引き起こしました。

引用元: hands-on-log.md §2

値 150 が .taxRates.inn と .population.elderMale で重なっている

手動で値を修正しようと試みるたびに、別の離れたフィールド間で連鎖的な値の衝突(Cascading Collisions)が発生したのです。実測ログに残された衝突の推移は以下の通りです。

  1. 税率と人口: 宿屋税率 150‰ と老人男性 150 人が衝突
  2. elder_male += 7 に調整: ターン数 37 と月の満ち欠け周期(slowMoonPhase)37 が衝突
  3. ターン数を 45 に変更: 月の満ち欠け周期が再度計算され、ターン数 45 と月の満ち欠け 45 が再衝突
  4. ターン数 200 を試行: 未着工工事数 0 と春の季節インデックス 0 が衝突
  5. ターン数 210 を試行: 老人男性 72 人と子供男性 72 人が衝突
  6. ターン数 220 を試行: 老人男性 70 人と住宅施設数 70 が衝突

37個もの整数値が数百程度の限られた値域に分布する場合、誕生日のパラドックスと同様に、意図しない値の一致が起こる確率は極めて高くなります。しかも、シミュレーションモデル内部で人口動態や天体周期、経済パラメータが非線形に連動しているため、1つの変数を動かすと別の変数が連鎖的に変動し、手動による目分量の調整は完全に泥沼化してしまいました。

私の実感としても、ゲームのバランス調整は、モデルが理論的に正しくてもゲームとして成立しなければ意味がなく、何度も実際に動かして感覚的な違和感がないか(手触りやプレイフィール)を判断基準に据える必要があります。テストデータの設計もこれとまったく同じです。頭の中の想定だけで「これで重複はないだろう」と手動で調整しても破綻しやすく、機械的なフィードバックループが不可欠でした。

メタアサーションの実装と「必然的一致」の除外設計

この泥沼を抜け出すために確立したのが、テスト自身が入力データの前提条件を検証する メタアサーション(自己 assert) です。

単に出力と期待値を比べるだけでなく、テスト実行時に得られた JSON ツリーをスタック走査し、すべての数値フィールドが一意であることを HashMap で検証します。

引用元: プロジェクトA(非公開)の lib.rs

// 値が重なると、その組の取り違えを検出できない。carrying は最小の制約と同値になるので除く。
let mut seen = std::collections::HashMap::new();
let mut stack = vec![(String::new(), &v)];
while let Some((path, node)) = stack.pop() {
    match node {
        Value::Object(m) => stack.extend(m.iter().map(|(k, x)| (format!("{path}.{k}"), x))),
        Value::Number(n) if path != ".capacity.carrying" => {
            if let Some(prev) = seen.insert(n.to_string(), path.clone()) {
                panic!("値 {n} が {prev} と {path} で重なっている");
            }
        }
        _ => {}
    }
}

ここで重要な設計判断となったのが、ドメイン上の「必然的一致」をどう扱うか という点です。

現実のシステムでは、すべてのフィールドを機械的に相異なる値にできるとは限りません。例えば本システムにおける最大収容力(carrying)は、食料や水、住宅といった各資源の制約のうち「最も小さい値」を採用するというドメインルールが存在していました。したがって、carrying は常にいずれかの制約値と一致してしまいます。

これを無理に解消しようとするとテストが成立しなくなるため、上記のコードのように path != ".capacity.carrying" として、除外理由をコメントに明記した上で意図的にチェックから外すホワイトリスト設計を採用しました。何が偶発的な重複であり、何が仕様上不可避な一致なのかを境界づけることが肝要です。

最終的に、探索ループを経てターン数を 205 まで進め、各施設の規模と税率を調整することで、全37項目の重複を完全に解消したテストデータを確立できました。この状態で Spec Review で指摘されたスワップ変異(農業施設数と鍛冶屋税率の入れ替え)を再度注入したところ、テストは確実に失敗し、バグを捕獲できるようになりました。

引用元: hands-on-log.md §3

    s_mut = s.replace("        f.agriculture,\n", "@@@").replace("        t.smithy,\n", "        f.agriculture,\n").replace("@@@", "        t.smithy,\n")
FAILED

テスト入力設計アプローチの比較

単体テストにおける入力データの設計アプローチには、実装の簡便さと欠陥検出力の間に明確なトレードオフが存在します。

アプローチ手法フィールド取り違えの検出力保守コスト・課題
手動値調整(デフォルト)似た項目(男女比など)に別々の値を目分量で割り当てる低(危険): 離れた区画の偶発的重複を見落とすテスト変更のたびに重複が再発しやすい
単純ランダム / ファジング乱数ジェネレータで毎回異なる値を注入する中(不安定): 偶発的一致による間欠的失敗(Flaky)の恐れ5失敗時の再現性が低くデバッグが困難
全値相異+メタアサーション全項目を相異なる値にし、テスト自身で重なりを assert する高(確実): スワップ変異を確実に捕獲初期値探索のコストと必然的一致の除外設計が必要

xUnit パターンにおける Distinct Generated Value は、元来データベースの一意制約違反やテスト間の干渉を防ぐ目的で提唱されたパターンでした5。しかし本事例が示すように、この概念は構造体や位置引数のスワップ欠陥を検知するための「入力値の対称性を壊す(Breaking Input Symmetry)」強力な設計原則へと発展させることができます。

言語境界や位置引数が残るコードベースでは、全値相異を前提としつつ、テストコード自身がその前提条件を検証するメタアサーションの組み合わせが最も確実な防壁となります。

前提が変われば結論はどう変わるか:境界の進化と将来展望

手書き JSON から型付きバインディング・IDL への昇格判断

ここまで手書き JSON におけるテスト堅牢化を論じてきましたが、そもそも「手書き JSON」という選択自体が過渡期のものである点には留意が必要です。

プロトタイプ初期においては、serde などの外部依存を本体クレートから排除し、高速なビルドとバイナリサイズの極小化を図るために手書きフォーマットが有効に機能します。しかし、The Rust and WebAssembly Book が指摘している通り、format! などの文字列フォーマット処理自体も Wasm バイナリの肥大化(bloat)を引き起こす要因となります6。

プロジェクトが成長し、受け渡すデータ構造が複雑化した段階では、以下のような一段上のアプローチへ昇格させることが推奨されます。

  1. serde-wasm-bindgen による直接シリアライズ: JSON 文字列を中継せず、Rust のデータ型を JavaScript のネイティブオブジェクト(JsValue)へ直接変換します。JSON パースのオーバーヘッドをなくし、コードサイズと実行速度の両面で優れた公式推奨のアプローチです78。
  2. IDL(インターフェース記述言語)による自動生成: Mozilla UniFFI のように、スキーマ定義から各言語向けの型付きグルーコードを自動生成するツールを導入します9。コンパイラが静的型チェックを行えるため、位置引数の取り違えといった人為的ミスはビルド時に完全に排除されます。

型安全な自動生成機構を導入できるのであれば、それに越したことはありません。しかし、レガシーな FFI 境界、サードパーティ API との連携、あるいは初期プロトタイプのように手書きマッピングを選択せざるを得ない局面は現場に数多く存在します。そのような境界において、静的型チェックの不在を埋める最後の砦となるのが「全値相異+メタアサーション」というテスト設計なのです。

よくある質問(FAQ)

Q. 全値相異にするだけで十分なら、なぜテスト内で重複アサーションまで行う必要があるのですか?

A. 将来の仕様変更や初期値変更による「サイレントなテストの腐食(偽陰性化)」を防ぐためです。手動で一度重複を解消しても、後の機能追加でフィールドが1つ増えたりデフォルト値が変わったりした瞬間に、再び偶発的な値の一致が発生してテストの検出力が失われる危険があります。テスト自身が前提条件を assert することで、腐食を即座に検知できます。

Q. ドメインの制約上、すべてのフィールドを異なる値にできない場合はどう対処すべきですか?

A. 構造上必然的に一致する項目(最小制約で決まる容量など)は、アサーションの走査ループ内でホワイトリストとして明示的に除外します。重要なのは「何が重複してはならず、何が必然的に重複するのか」をテストコード上にドキュメントとして残し、意図しない偶発的な重複だけを確実に防ぐことです。

まとめ

  • 全体パース照合は万能ではない: 出力全体をパースして比較していても、入力データ内で値が重なっているフィールド同士の取り違えは原理的に見逃されます。
  • 入力値の対称性を壊す(Breaking Input Symmetry): 位置依存の引数や手書きシリアライズをテストする際は、すべての項目に異なる値を割り当てて対称性を排除しなければなりません。
  • メタアサーションで前提条件を保護する: テストデータが「全値相異」を満たしていること自体をテストコード内で assert することで、将来の仕様変更によるテストの腐食を機械的に防ぎます。
  • 必然的一致はホワイトリストで明示する: ドメイン制約上どうしても重複する項目は、除外理由をテストコードに記録し、意図せぬ偶発的重複と明確に区別します。
  • 境界の進化を見据える: プロトタイプ初期の手書きマッピングは全値相異で守りつつ、規模拡大に伴い serde-wasm-bindgen や IDL 自動生成(UniFFI)への昇格を検討します。
  • AI コーディング時代のフルスタック選択: 作成コストが下がった現代では、フロントエンド・バックエンドを同一言語(Rust)で揃える利点が大きく、境界のテスト設計さえ堅牢にしておけば安心して試行錯誤に没頭できます。

開発プロセスの標準化、自動テスト基盤の堅牢化、AI コーディングを活用したアーキテクチャ設計について、ぜひ コンタクトフォーム よりお気軽にご相談ください。

Footnotes

  1. cargo-mutants User Guide: Mutant outcomes Martin Pool、2026年11月確認。Mutant outcomes 節における caught と missed の定義、テストの死角警告。 ↩

  2. PIT (Pitest) Mutation operators: Available mutators and groups PIT (Pitest) 公式ドキュメント、2026年11月確認。Mutation operators 節における変異体と等価変異体(equivalent mutations)の定義。 ↩

  3. Why Snapshot Testing Is the Secret Weapon for API Stability Kreya、2026年2月23日公開。Catching everything 節および The whole response is under guard 節におけるスナップショット完全性の言説。 ↩

  4. Jest: Snapshot Testing Meta Platforms, Inc.、2026年11月確認。Snapshot Testing 節におけるスナップショットテストの基本理念。 ↩

  5. xUnit Test Patterns: Generated Value / Distinct Generated Value Gerard Meszaros、Addison-Wesley、2007年。Distinct Generated Value パターン定義節におけるテスト入力の一意化による干渉回避。 ↩ ↩2

  6. The Rust and WebAssembly Book: Shrinking .wasm Code Size The Rust and WebAssembly Working Group、2026年11月確認。Shrinking .wasm Code Size 節における Avoid String Formatting 指針。 ↩

  7. docs.rs: serde_wasm_bindgen Cloudflare、docs.rs、2026年11月確認。crate ドキュメント冒頭におけるコードサイズと実行速度の優位性解説。 ↩

  8. The wasm-bindgen Guide: Arbitrary Data with Serde The wasm-bindgen Guide、2026年11月確認。Arbitrary Data with Serde 節における複合型の型付きシリアライズ移行手順。 ↩

  9. Mozilla UniFFI: Motivation Mozilla、2026年11月確認。Motivation 節における手書き FFI コードの脆弱性と IDL 自動生成への昇格判断基準。 ↩