シミュレーションや長期バッチ処理、複雑な状態遷移ロジックを開発していると、「1期の単体テストでは気づけず、何十期も長期実行して初めて表面化するバグ」に出くわすことがあります。そうしたバグの修正後には、再発を防ぐために長期間を一気に早送りして推移を検証する「長期回帰テスト」を追加し、テストスイートがすべて合格(Green)になるのを見て胸をなでおろすのが一般的な開発風景かもしれません。

しかし、その回帰テストは本当に過去のバグの再発を検出できるでしょうか。通常のテスト駆動開発(TDD)では、最初にテストが失敗(Red)することを確認してから実装を書いて Green にするため、テスト自体が正しく機能している保証が得られます。これに対し、バグ修正後や既存のコードに対して後から回帰テストを追加する場合、テストは最初から Green になってしまいます。そのため、そのテストが「本当に過去のバグの再発で失敗できるのか」を一度も確認できない落とし穴が存在します。

個人で開発中のある長期シミュレーションプロジェクト(非公開、以下プロジェクトA)において、50年(1,250期)の長期実行で発覚した4件のバグを防ぐ長期回帰テストを追加した際、コードレビューでワークツリーを別領域にコピーし、修正したバグコードをあえて埋め戻す「手動コード変異(Mutation)」による逆検証を行いました。その結果、4件中2件が当初想定していたテスト条件で全く捕まっていないことが判明しました。

回帰テストのコード変異逆検証(Mutation Testing)とは、過去に修正したバグやコードの変更を意図的に埋め戻し、テストスイートが失敗(赤)して欠陥を検知できるかを確かめる検証手法です1。Martin Fowler がテストカバレッジの限界として、カバレッジの数値自体はテストの良質さを示さず、アサーションが存在しないテスト(AssertionFreeTesting)でも高いカバレッジが達成できてしまうと警鐘を鳴らしているように2、テストがパスしたという事実だけでは、バグを防ぐアサーションが正しく機能しているとは限りません。

本記事では、長期回帰テストに対してバグを再注入して判明した「正常な別経路による欠陥マスク」と「アサーションの死角」の実測ログを解剖し、テストコードを無理に全知全能に見せかけるのではなく実測に合わせてコメントを誠実にリファクタリングした判断と、複数要素が絡むシステムのテスト設計について共有します。

50年回す回帰テストが「Green」になった安心と油断

プロジェクトAでは、毎期の保存則や三面等価といった不変条件を検証する1期単位のテスト(前作の記事 で触れたようなテスト群)を網羅的に整備していました。これらはすべてパスしていたにもかかわらず、50年(1,250期)にわたって連続実行したところ、長期の累積でしか現れない4件のバグが表面化しました。

  1. 人口変化の端数切り捨てによる人口静止(#47): 毎期の人口変動計算で端数が切り捨てられ、人口規模が小さいと1人も動かなくなる。
  2. 移住ロジックの呼び出し漏れ(#50): 都市の収容力拡大などの施策を打っても、移住処理が呼ばれていないため人口推移に反映されない。
  3. 男女振り分けの切り捨てによる極端な女児偏り(#53): 出生の男女割り当て計算で端数が切り捨てられ、生まれる子供がほぼ全員女児になる。
  4. 成人男女比の偏り(#58): 成人の男女比率が不均衡なまま推移する。

これら4件のバグを修正したあと、再発防止のために50年(1,250期)を高速に早送りして推移を検証する長期回帰テストスイート long_run_test.rs(4本のテスト)を追加しました。

追加されたテストは、人口推移を調べる test_population_moves_over_50_years、子供の性比を調べる test_child_sex_ratio_stays_in_range、冒険者の生存を調べる test_adventurers_never_die_out、そして施策の有無による分岐を調べる test_population_focus_diverges_from_no_policy の4本です。モジュール冒頭には「50年実行で初めて現れる4件のバグ(#47, #50, #53, #58)をすべて捕捉する」とコメントが書かれ、人口推移テスト内には「このアサーションで #47 を防ぐ」と明記されていました。

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

#[test]
fn test_population_moves_over_50_years() {
    let run = play(Strategy::None);
    let initial = run.totals[0];
    assert!(
        run.totals.iter().any(|&t| t != initial),
        "population never moved from {initial} (#47)"
    );
    assert_ne!(run.totals.last(), Some(&initial));
}

このテストコードを実行すると、全4本のテストが Green で通過しました。テストが通り、アサーションのメッセージにもバグ番号が掲げられていたため、一見すると過去のバグ4件に対する完璧な防壁が完成したかのように見えました。しかしこの時点では、「修正したバグが再発したときに、このテストが本当に失敗するか」という逆方向の検証は行われていませんでした。

コード変異(バグ埋め戻し)で逆検証したら2件がすり抜けた実測

コードレビューにおいて、レビュアーはワークツリーを別ディレクトリ(スクラッチ領域)に複製し、修正コミットのコードをあえて過去の不具合コードに書き換えてテストを実行する「手動ミューテーション検証」を行いました。なお、この検証時には状態観測用のプローブテストが1本追加されていたため、テストスイートは計5本で実行されています。

過去のバグ4件に関するコード変異および状態観測に対する、長期回帰テストスイートの検出可否の実測結果は以下の通りです。

バグ種別検証時に実施した操作・変異当初想定していたテスト変異・観測時の実測結果検知可否とすり抜け原因
#47: 端数切り捨て剰余バッファ破棄、端数切り捨て再注入test_population_moves_over_50_years単体テスト通過(最終人口889まで動く)❌ 単体テスト素通り(移住ロジックによるマスク)※施策差分テストでのみ検知
#50: 移住ロジック漏れadvance_migration 呼出削除test_population_focus_diverges_from_no_policyFAILED(無施策・人口重視が人口232で一致)⭕ 正常に検知
#53: 女児出生偏り男児出生率計算の改変test_child_sex_ratio_stays_in_rangeFAILED(男児比率が17〜19‰に低下)⭕ 正常に検知
#58: 成人男女比の偏り初期値および女児変異時の成人比率観測全テスト(モジュール冒頭で捕捉宣言)成人比率で失敗するテストは0本❌ 検証の死角(初期値の偏り697‰ + 成人男女比のアサーション不在)

この実測が示すように、「4件のバグをすべて捕捉する」と謳っていたテストスイートは、実際にコード変異で逆検証すると当初想定したテスト条件では半分(2件)しか意図通りに検知できていませんでした。

すり抜け1: #47型(端数切り捨て)のマスク現象

端数切り捨てによる人口静止(#47)を検証するため、population.rs 内の剰余バッファを破棄し、端数切り捨てに戻す変異を加えました。

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

*remainder = 0; let _ = u64::try_from

この状態でテストを実行したところ、#47 を防ぐはずだった test_population_moves_over_50_years は 何事もなく Green で通過 しました。実行結果ログは以下の通りです。

.PROBE none child 500-500 adult 697-697 final Some(889) minadv 101
thread 'test_population_focus_diverges_from_no_policy' (10960779) panicked at crates/trillion-core/tests/long_run_test.rs:85:5:
..PROBE population child 500-500 adult 697-697 final Some(889) minadv 101
test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

なぜ単体テストが通ってしまったのか。原因は、自然増減が端数切り捨てによってゼロに静止していても、システム内の別の流入経路である「移住(advance_migration)」が生きていたためです。移住によって外部から人口が流入し、最終人口は 889 に達したため、run.totals.iter().any(|&t| t != initial) というアサーション条件を軽々と満たして素通りしてしまいました。

ソフトウェアテストの分野では、ある欠陥の存在が別の欠陥によって覆い隠され、テストで顕在化しない現象を「欠陥マスク(Defect Masking)」と呼びます34。しかし本件で起きていたのは、「別の欠陥」によるマスクではなく、「正常に動いている別の処理経路」が欠陥の症状を打ち消して隠蔽する という、より発見が難しいマスク現象でした。

幸いにも、施策ごとの人口差分を比較する test_population_focus_diverges_from_no_policy は、端数切り捨てによって施策による自然増の差がつかなくなったためパニックを起こし、FAILED となってくれました。つまり #47 は捕捉されたものの、テストコードに「(#47) を防ぐ」と書かれていたテスト単体では完全に素通りし、別のテストの比較ロジックに救われていたに過ぎなかったのです。

すり抜け2: #58(成人男女の偏り)のアサーション不在

さらに深刻だったのが、成人男女比の偏り(#58)でした。

内部状態をプローブで調査したところ、初期状態の時点で成人の男性比率は 697‰(約7割男性) と元から極端に偏っていました。さらに #53 の女児出生偏り変異を加えた極端な環境下では、成人男性比率は 177‰ まで低下しました。

しかし、正常時(697‰)であれ変異時(177‰)であれ、成人の男女比をチェックするアサーションはテストスイート内に 1行も存在しませんでした。そのため、成人の男女比率がどれほど異常な値を示していようと、それを理由に失敗するテストは0本でした。

テストスイートのモジュール冒頭には「#58 を捕捉する」と書かれていたにもかかわらず、実際にはテストが一切アサーションを行っていない、まさに Martin Fowler の言う AssertionFreeTesting の状態に陥っていたのです2。

正常に捕捉された2件

一方で、残る2件のバグ変異については、狙い通りのテストで確実に検知できました。

  • #50型(移住抜け): city.rs から advance_migration の呼び出しを削除したところ、無施策と人口重視施策の最終人口がともに 232 で一致し、test_population_focus_diverges_from_no_policy がパニックして失敗しました。さらに冒険者数が 0 になったことで test_adventurers_never_die_out も失敗し、多角的に検知されました。
  • #53型(女児出生偏り): population.rs で男児出生率を極端に低く改変したところ、男児比率が 17〜19‰ まで急落し、test_child_sex_ratio_stays_in_range が意図通り失敗しました。

テストコードを直すのではなくコメントを誠実にリファクタリングする

この変異検証の結果を受けて、コードレビューでは「テスト要件自体は満たされているが、#47 と #58 の回帰はコメントが示唆する形では捕まっていない」「冒頭のヘッダーコメントは #58 については実態と異なり、#47 については施策差分テスト経由でのみ成立する」という指摘が出されました。

ここで取り得た選択肢は2つありました。1つは、その場でテストコード本体を急いで書き換え、人口推移テストに自然増減単体を見るアサーションをねじ込み、成人男女比のアサーションを無理やり足して「4件すべてを綺麗に捕捉できるテスト」を作り上げることです。

しかし、選択したのはもう1つの道でした。テストコード本体のロジックは急造でいじらず、テストコードのコメントを「実際にコード変異で確かめられた捕捉範囲」に合わせて誠実にリファクタリングする という判断です。

具体的には、以下の修正を行いました。

  1. test_population_moves_over_50_years のアサーションメッセージから、誤認を生んでいた (#47) というラベル記述を削除。
  2. モジュール冒頭のコメントを、変異検証の実測に基づき「変異で確認された捕捉範囲(Caught)」と「捕捉されなかった範囲(Not caught)」へと全面的に書き換え。
  3. test_population_focus_diverges_from_no_policy のコメントに、変異時の実測値(移住を抜くと両方の人口が 232 で一致した事実)を明記。

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

//! Long-run behaviour from the initial state (#70). Per-term correctness is covered elsewhere;
//! these tests catch what only shows after many terms.
//!
//! Caught (confirmed by mutation): #47-style truncation (through the divergence test only),
//! #50 (migration missing) and #53 (lopsided births).
//! Not caught: #58 (adult sex imbalance). The initial adult male share is already 697‰ and no
//! test checks the adult ratio.

修正後のテスト実行結果は、当然ながら 4 passed のままです。

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

テストの実行結果自体は変わりませんが、コードを読む人に対して「このテストは何を守れていて、何を守れていないのか」という真実を正確に伝えるドキュメントになりました。そして、テストで捕捉できなかった #58 については、見なかったことにせず後続の課題として Issue を起票し、追跡可能な状態に残しました。

「テストが通ること」をゴールにするのではなく、「テストが何を検知できて、何を検知できないのかの境界」を誠実に記録することこそが、長期的なコードベースの信頼性を支えます。

複数要素が絡む値のテスト設計と手動変異のトレードオフ

今回の失敗とリファクタリングを通じて、シミュレーションや状態遷移システムのテスト設計における本質的な課題が浮き彫りになりました。

複数要素が合算される値のテスト設計

人口のように、複数の要素(自然増減、移住、出生、死亡など)が組み合わさって増減する値は、総合値(人口総数)だけを長期テストしても、要素同士の打ち消し合いや別経路のマスクによって欠陥が容易にすり抜けてしまいます。

テストを設計する際は、大雑把に「50年後に人口が動いていること」を丸ごと検証するのではなく、数値を構成する個々の要素(自然増減単体、移住単体)の長期的な振る舞いやアサーションを明確に切り出して検証する必要があります。

初期値設定と理論モデルのギャップ

また、大規模(例えば10万人規模)を想定した計算ロジックであっても、初期状態が100人程度の少数だと、1期あたりの変動が端数で切り捨てられて人口が動かなくなるといった現象が起きます。

頭の中の想定規模と実際の設定にはギャップが存在します。理論上正しい式を組んだつもりでも、「初期状態の実際の設定」で長期に動かしてみて初めて認識できる問題があります。さらに #58 のように初期値(成人の男性比率 697‰)そのものが最初から偏っている場合、テストで比率を検証するアサーションがなければ、異常な状態のまま推移していても誰も気づくことができません。

後追い回帰テスト特有の非対称性

冒頭でも触れた通り、通常の TDD であれば Red(失敗)を確認してから Green(成功)にするため、テストが機能していることが自然に担保されます。

しかし、バグ修正後や既存コードに対して後から回帰テストを追加する場合、テストは最初から Green になってしまいます。テストが成功したという結果だけを見て安心してしまう心理的バイアスを打ち消すためには、あえてバグを埋め戻すコード変異によって「本当に赤になるか」を後から確かめる逆検証が不可欠です。

手動変異(Manual Mutation)の限界とトレードオフ

今回行ったような手動でのコード変異には、専用ツールの導入なしに手元ですぐに実行でき、過去に起きた具体的なバグの挙動を手早く検証できるという大きなメリットがあります1。

一方で、手動変異には明確な限界があります。人間が「思いついたバグ(既知のバグ)」しか埋め戻すことができず、コードベース全体に対する網羅的な変異検証を行うには多大な工数がかかります。そのため、未知の死角や想定外のアサーション抜けをすべて手動で洗い出すことは困難です。

CIでの差分変異テストとプロパティテストへの展開

手動変異の確実性を活かしつつ、網羅性の限界を克服するためには、自動化ツールや先進的なテスト手法との連携が視野に入ってきます。

Rust エコシステムにおける cargo-mutants

Rust においてミューテーションテストを自動化する代表的なツールが cargo-mutants です56。cargo-mutants はソースコードを構文解析し、条件式の反転、戻り値の変更、演算子の置き換えといった変異体を自動生成してテストスイートを実行します。

もしコードを改変してもテストがパスしてしまった場合、その変異体は「生き残り(missed mutant)」として報告されます。生き残った変異体は、まさに「どのテストも検査していないアサーションの穴」であることを意味します5。

差分変異テスト(--in-diff)による CI 実用化

しかし、コードベース全体に対してミューテーションテストを実行すると、膨大な数の変異体に対してビルドとテストが繰り返されるため、実行時間が数十分から数時間に及んでしまいます。Google のような大規模環境でも、計算コストを抑えるためにステートメントカバレッジのない行や関心の低い行(arid lines: 乾燥行)を除外し、コードレビュー画面に変異結果を表示して開発者の注意を促す仕組みを採用しています7。

一般的なプロジェクトにおいて現実的な時間で変異テストを運用するための強力な武器が、差分変異テストです。cargo-mutants には --in-diff オプションが用意されており、直近の git diff で変更されたコード領域にのみ変異を限定して実行することができます8。

# 直近のコミット差分に対してのみ変異テストを実行
git diff origin/main...HEAD > /tmp/pr.diff
cargo mutants --in-diff /tmp/pr.diff

差分に絞り込むことで、プルリクエストの CI ゲートとして数分以内で変異テストを完了させることが可能になります。ただし、差分外に潜んでいる既存の死角は見逃してしまう点や、テストコードだけを変更した差分では変異が実行されないという制約には注意が必要です8。

プロパティベーステスト(proptest)との組み合わせ

変異テストによって発見されたアサーションの穴を塞ぐアプローチとして、固定値のテストケースを手動で追加するだけでなく、proptest などのプロパティベーステストを活用する手法も非常に相性が良いとされています5。

ランダムな入力空間を自動探索して不変条件を検証するプロパティテストと、アサーションの抜け漏れを機械的に炙り出すミューテーションテストを組み合わせることで、長期シミュレーションや複雑なビジネスロジックにおける死角を極小化していくことができます。

よくある質問(FAQ)

Q. ミューテーションテストを手動で行うのと、cargo-mutants などのツールを使うのでは何が違いますか?

A. 手動変異は「過去に実際に起きたバグ」をピンポイントで埋め戻して回帰テストの感度を即座に確かめられる一方、想定外のバグは検出できません。ツールによる自動変異は、演算子や戻り値の機械的な改変によって人間が気づかないアサーションの抜け漏れを網羅的に洗い出せますが、実行コストが大きくなります。実務では、バグ修正時には手動で埋め戻して赤を確認し、CI では差分変異テスト(--in-diff)で自動検証するという使い分けが効果的です。

Q. シミュレーションや長期バッチの回帰テストで、別処理によるマスク現象を防ぐにはどうすればよいですか?

A. 総合値(人口総数や残高総額など)だけをアサーションの対象にせず、数値を構成する個別の要素(自然増減、移住、手数料など)ごとに独立した長期アサーションを設けることが基本です。複数要素が互いに打ち消し合ったり、別の正常な流入経路が欠陥を覆い隠したりするのを防ぐため、施策の有無による差分比較テストや、特定経路を遮断した状態での単体推移テストを組み合わせて検証します。

まとめ

本記事で取り上げた長期回帰テストの変異検証から持ち帰るべき要点は、以下の通りです。

  • 回帰テストはバグを埋め戻して赤になるまで信じない: 後から追加した回帰テストは最初から Green になってしまうため、コード変異(Mutation)で意図的にバグを再注入し、テストが確実に失敗するかを逆検証する手順を必須とする。
  • 総合値のアサーションは別経路によるマスクに注意する: 複数の処理経路が合算される値では、ある欠陥が別の正常な処理によって覆い隠され、単体テストを素通りすることがある。要素ごとの長期推移や差分アサーションを切り出して検証する。
  • 死角が判明したらテストコメントを誠実にリファクタリングする: 変異検証でテストの穴が見つかった際、テストコードを無理に全知全能に見せかけるのではなく、コメントを実測された捕捉範囲に書き直し、未捕捉を後続課題として可視化する。
  • 手動のピンポイント変異と CI での差分自動変異を組み合わせる: 日常のバグ修正では手動バグ埋め戻しで迅速に逆検証し、プルリクエストでは cargo-mutants --in-diff などの差分変異テストでアサーションの死角を自動検知する。

長期バッチや複雑なシミュレーションの回帰テスト設計、テストの形骸化防止、および AI 協業による信頼性の高い品質保証プロセスの構築について、ぜひ コンタクトフォーム よりお気軽にご相談ください。

Footnotes

  1. bliki: Mutation Testing Martin Fowler、2026年11月5日参照(著者の bliki 上のエントリ)。変異テストの基本概念(コードに小さな変更を加えてテストの穴を検出する)および手動変異テストの手軽さと有効性は本文全体。LLM生成テストの評価への応用。 ↩ ↩2

  2. bliki: Test Coverage Martin Fowler、2026年11月5日参照。カバレッジ数値の限界と、アサーションが存在しない AssertionFreeTesting のアンチパターンへの警鐘。 ↩ ↩2

  3. Defect Masking in Software Testing | What it is & Why it Matters? Testsigma、2026年11月5日参照。Defect Masking(欠陥マスク)の定義(ある欠陥が別の欠陥や事象の存在によってテストプロセス中に検出されずに素通りする現象)。 ↩

  4. Hidden Quality Blockers: What Are Latent And Masked Bugs QATestLab、2026年11月5日参照。Masked bug(別のエラーによって実行や顕在化が妨げられた既存エラー)と Latent bug(特定条件が揃うまで活動しない潜伏エラー)の比較定義。 ↩

  5. cargo-mutants でテストの穴を測って、proptest と kani で埋める (追記 miri) mizchi、2026年11月5日参照。cargo-mutants を「テストがどこを検査していないか(テストの穴)を測る道具」と位置づけ、生き残った変異体を proptest や kani で埋める実践手法。 ↩ ↩2 ↩3

  6. How cargo-mutants works Martin Pool、2026年11月5日参照。cargo-mutants の動作原理(syn によるパース、テキストパッチ適用、cargo test --no-run によるコンパイル確認)。 ↩

  7. State of Mutation Testing at Google Goran Petrovic, Marko Ivankovic, 2026年11月5日参照。変異分析の計算コスト抑制のためのステートメントカバレッジのない行および arid lines(乾燥行・無意味行)除外手法、ならびにコードレビュー連携。 ↩

  8. Testing code changed in a diff Martin Pool、2026年11月5日参照。cargo-mutants --in-diff による変更差分領域に限定した変異テストの実行仕様、および差分外の欠陥やテストコードのみ変更時の制約。 ↩ ↩2