ミューテーションテストは否定的。TDD のテストは肯定的。
どちらもテストと呼ばれますが、主張の向きは正反対です。TDD のテストはシステムが何をすべきかを述べます。ミューテーション実行が報告できるのは、テストスイートが気づかなかったことだけです。どちらの符号を手にしているかが結果への対処を決め、エージェントが書いたテストを信用できるかも決めます。
2 種類の主張
テスト駆動のテストは、振る舞いが存在する前に書かれます。まず失敗し、次に通り、それ以降は常設の言明になります。この入力はこの結果を生まなければならない、という言明です。スイートは読める仕様として積み上がり、先に書くという行為が設計に圧をかけます。呼び出しにくいコードはテストしにくいからです。ミューテーション実行は構造的に別のことをします。すでに動くコードを取り、意図的に書き換え、スイートを再実行します。結果はいつも否定形です。この改変は気づかれなかった、と。その出力のどこにも、ソフトウェアが何のためにあるのかは書かれていません。
緑の TDD スイートは意図についての言明の集合です。高いミューテーションスコアは、スイートに対する特定種類の反証が存在しないことにすぎません。仕様と呼べるのは片方だけです。
肯定的なテスト
- + コードより先に書かれるため、要求はロジックになる前に言葉として存在する
- + 最初に失敗する。テストがそもそも失敗しうることを示す、唯一安価な証明である
- + 規則に名前を与えるのでリファクタリングを生き延びる。内部は動いてもアサーションは動かない
- + 設計がまだ安く変えられるうちに、設計へ圧をかける
- + 次の人が、そして次のエージェントがドキュメントとして読める
否定的なチェック
- − 事後に走る。すでに存在するコードとテストに対して
- − 殺されたミュータントは新しい情報を伝えない。信号を運ぶのは生存者だけ
- − 生存者は盲点の証拠であり、製品の欠陥の証拠には決してならない
- − 欠けている要求については何も言わず、試した変更への鈍感さだけを言う
- − レポートとタスク一覧を生むが、誰かが保管する成果物は生まない
同じ言葉、異なる 2 つの計器
| 肯定的なテスト | 否定的なチェック | |
|---|---|---|
| 何を主張するか | システムはこう振る舞わなければならない。 | あなたのスイートはこの変更に気づかなかった。 |
| 緑が証明すること | これまでに仕様化した振る舞いはすべて実装されている。 | その演算子集合に対して感度がある。正しさについては何も言わない。 |
| 後に残るもの | テストと、書かされたことで形づけられた設計。 | レポート。価値は失効する前に抜き出さなければならない。 |
| いつ走るか | コードを書いている間、分単位で継続的に。 | CI で。変更ファイルへの増分実行か、夜間の全体スイープ。 |
| コストの構造 | 着実に支払われ、開発に織り込まれる。 | ミュータント数 × 関連テストの実行時間。双方に比例して増える。 |
| 設計への影響 | テスト容易性への圧。扱いにくい設計はすぐ痛む。 | なし。すでにあるものを採点するだけ。 |
| 失敗の届き方 | 赤。意図的に、あなたが書き、1 歩ずつ。 | 誰も書いていない生存者。いまから解釈しなければならない。 |
実際にどう回すか
ミューテーションテストには「高くつく」という評判がありますが、それはほぼ常にツールの問題ではなくスコープの問題です。この手順なら、納期との接触に耐えられる程度に安く保てます。
まずカバレッジを整える
- - カバーされていないコードへのミューテーションは、当たり前のことを高い費用で報告するだけ
- - 安価な常設ゲートはカバレッジに任せ、ミューテーションはすでにカバー済みのコードに向ける
- - リポジトリ全体ではなく、重要な 1 モジュール(決済、権限、価格計算など)を選ぶ
実行ごとに範囲を絞る
- - テスト単位のカバレッジ解析が最大のレバー。各ミュータントに到達するテストだけを走らせる
- - プルリクエストでは変更ファイルだけを増分モードで変異させる。それなら数分の実行で済む
- - 網羅スイープはスケジュールに置き、クリティカルパスの外で、長時間が誰の負担にもならない場所で回す
生存者を 3 つの箱に分ける
- - 欠けている要求。規則にちなんで名づけた新しい肯定的テストになる
- - 観測可能な振る舞いを変えない等価ミュータント。一度記録して除外する
- - どの要求も求めていないコード。削除する。これが最も安い撃墜
数値ではなく後退をゲートする
- - スコアの低下を止める break しきい値を設定し、そこで止める
- - 生存者はビルド失敗ではなくレビュー項目として報告する。赤いビルドを無視する習慣を作らないため
- - 実行時間を一級のメトリクスとして追う。遅いから無効化されたゲートは何も守らない
コードを書くのが LLM になると何が変わるか
エージェントがこの問題を作ったわけではありませんが、産業化しました。テストと実装の著者が同一で、しかもそれがモデルである瞬間から、符号の話は哲学をやめます。
エージェントに「テスト付きで機能を」と頼めば両方が返ってきます。ただし、要求の 1 つの読みから、1 つのコンテキストで生成されます。その読みが誤っていれば、テストは同じ誤解を符号化して通ります。しかもカバレッジは見事に見えます。モデルが書いた各行は、それを実行させるためにモデルが書いたテストで実行されるからです。これは通常のレビューが最も苦手とする失敗です。diff の中でテストがもっともらしく見えてしまうからです。ミューテーションテストはこれを捕まえる最も安価な自動チェックであり、理由は 1 つです。テストが存在するかを問わず、反応するかを問うからです。実装を鏡写しにしたテストは、実装が変わっても気づきません。
レポートではなくループの中に置く
生存ミュータントはエージェントにとって異例に良いフィードバックです。実行可能で、具体的で、言い抜けができません。レビューの助言の段落は受け流されますが、「この変更が検出されなかった」という指摘は直されます。これは Uncle Bob の SwarmForge の hardener ロールの形そのもので、ミューテーションツールを 1 ファイルずつ走らせ、生存者を片付けるまで前に進めません。
基準を「書くときの指示」へ移す
あのプロジェクトで最良の一文は coder ロールのプロンプトにあります。もっともらしい誤実装なら失敗するようなテストを書け、というものです。これは生成の段階に埋め込まれたミューテーションの基準で、1 時間後の監査で同じことを発見するよりはるかに安い。この一文を自分のエージェント指示に入れてください。
書き手とハードナーを分ける
同じモデルが同じコンテキストにいれば、生存者が問題ない理由を説明してきます。その穴を生んだ推論がまだ目の前にあるからです。ミューテーションのパスは別工程・別コンテキストで、元の言い分にアクセスさせず、diff とツール出力だけで走らせてください。
機械速度でのスコア追いを想定する
ミューテーションスコアを上げよと指示すれば、エージェントは誰もレビューしきれない速さでミュータント殺しの変更検知器を書きます。グッドハートの法則はエージェントでは悪化します。量が無料だからです。エージェントには生存者の報告と要求レベルのテストの提案までをさせ、「要求とは何か」の判断は人間か仕様ロールに残しましょう。
エージェントが出したコードを評価する
カバレッジとミューテーションスコアの組み合わせは、AI が書いた成果物の実用的な評価軸になります。対で読むこと。面白い情報は両者の不一致にあります。
| シグナル | 通常の意味 | 取るべき行動 |
|---|---|---|
| カバレッジは高く、ミューテーションスコアは低い | テストはコードを実行するために書かれ、検査するためには書かれていない。モデル生成テストの典型形。 | 実装はレビュー対象として残し、テストは破棄するか要求レベルで書き直す。 |
| 小さな diff で両方が高い | 本当に良い仕事か、うまく偽装された変更検知器のどちらか。 | スパイ、スナップショット、呼び出し回数アサーションを抜き取り検査。アサーションが規則を名指ししていれば出荷可。 |
| 生存者がエラー経路に集まる | モデルはハッピーパスを丁寧に実装し、残りは語っただけ。 | 失敗時の振る舞いを明示的に仕様化し、その仕様に対するテストを依頼する。 |
| どの要求も名指ししないコードの生存者 | 投機的な一般化。誰も頼んでいないオプション、フラグ、防御的分岐。 | コードを削除する。持ち主のいない未テストの複雑さである。 |
| スコアは上がったが、テストが内部に触れている | エージェントは、スイートを今日の実装に溶接することでメトリクスを最適化した。 | 却下。振る舞いが変わらないまま、次のリファクタリングでこれらのテストが壊れる。 |
| 等価ミュータントの除外リストが急速に伸びる | エージェントはスイートを良くするのではなくツールと議論している。 | 自分で diff を読む。除外は人間が一度、理由を記録して行う判断である。 |
同じ生存者、2 つの応答
ここで符号の話は哲学をやめます。ミューテーションレポートは場所を渡してくるだけで、次に何を書くかがスイートを強くするか硬くするかを決めます。
対象コードと、生き残るミュータント
js.trim() の除去は定番のメソッド呼び出し変異です。前後の空白を含むフィクスチャがなければ生き残ります。そして多くのフィクスチャは含みません。モデルが自分のコードのために作ったものも同じです。
// importer.js
const normalise = (value) => value.trim().toLowerCase();
export function importRows(rows) {
return rows.map((row) => ({ email: normalise(row.email) }));
}
// Surviving mutant: normalise() with .trim() removed.
// The suite passes either way, so the report flags it. ミュータントを殺し、スイートを結合させる
jsこのテストは生存者を殺します。同時に現在の実装を凍結します。normalise は到達可能なままで、ちょうどこの回数だけ呼ばれなければならない。名前を変え、インライン化し、境界の裏へ動かせば、振る舞いは何も変わっていないのにテストが壊れます。スコアを最適化するエージェントは、既定でこの形を生みます。
it('calls normalise once per row', () => {
const spy = vi.spyOn(internals, 'normalise');
importRows([{ email: ' Ada@Example.COM ' }]);
expect(spy).toHaveBeenCalledTimes(1);
});
// Green. Mutant dead. Score up.
// Nothing here states what an imported email address should look like. 生存者が投げた問いに答える
js同じミュータント、同じ撃墜ですが、アサーションはプロダクト側の人でも読める規則です。公開関数を通すので内部は自由に動かせますし、半年後の失敗メッセージが自ら説明してくれます。
it('trims and lower-cases every imported email address', () => {
expect(importRows([{ email: ' Ada@Example.COM ' }]))
.toEqual([{ email: 'ada@example.com' }]);
});
// Same mutant dead, but the suite gained a specification
// instead of a snapshot of today's call graph. 監査を回し続けられる安さに保つ
jsonテスト単位のカバレッジ解析が最大の性能レバーです。各ミュータントに実際に到達するテストだけを走らせます。増分モードならプルリクエストの実行を数分に収められます。break のしきい値は後退を防ぐ床であり、追いかける目標ではありません。
{
"testRunner": "vitest",
"coverageAnalysis": "perTest",
"incremental": true,
"mutate": ["src/**/*.js", "!src/**/*.test.js"],
"thresholds": { "high": 80, "low": 60, "break": 60 }
} エージェントが書いたブランチ向けのゲート
shコードを書いていない工程が投げる、独立した 2 つの問い。このコードは変更できる形をしているか。そしてそれを守るテストは、変更されたときに反応するか。両方の答えをプルリクエストに載せ、どちらも著者には交渉させません。
CHANGED=$(git diff --name-only origin/main... -- 'src/**/*.js')
# structural: complexity against coverage on touched methods
crap-report --changed-only --threshold 30 $CHANGED || exit 1
# behavioural: do the new tests notice anything?
stryker run --incremental --mutate "$CHANGED"
# survivors are review items with a named requirement attached,
# never a licence to write a test that pins the call graph. 否定が毒に変わるところ
スコアを追いかける
ミューテーションスコアが目標になった瞬間から、要求を述べるためではなくミュータントを殺すためのテストが書かれます。緑だからレビューを通り、振る舞いが何も変わっていない次のリファクタリングで壊れるのは、まさにそれらです。
等価ミュータントと戦う
観測可能な振る舞いを変えずにコードだけを変える変異があります。誠実なテストでは殺せません。記録し、除外し、先へ進んでください。ここに費やす時間が買うのは数値であって、確信ではありません。
規則ではなく変異を主張する
新しいテストが守る要求に名前を付けられないなら、それは変更検知器です。将来の編集すべてを失敗として報告し、チームにテスト出力を読まない習慣を教え込みます。
著者に自分の成果をハードニングさせる
コードを書いたモデルは、どの生存者についても「許容できる理由」を見つけます。自分の推論が自分にはまだ妥当に見えるからです。ハードニングは、diff とツール出力しか見えない工程で行わなければなりません。
すべての生存者を肯定的な言明に変える
生存者を問いとして読む
- - この変更が何かを壊すためには、どんな振る舞いが真でなければならないか?
- - 誰かが書き留めていたなら、いまどの要求が失敗しているのか?
- - この変異が本番に出たら、下流の誰が気づくのか?
撃墜ではなく答えを書く
- - テストはミュータントや行番号ではなく、規則にちなんで名づける
- - 公開面を通してアサートし、内部が変えられる自由を残す
- - 内部を露出させないと規則に届かないなら、それはテストではなく設計の発見である
あるいはコードを削除する
- - 名前を付けられる要求がないなら、その振る舞いには実際に何も依存していない
- - 仕様のないコードの生存者は、欠けたテストよりも死んだ要求であることが多い
- - 削除は最も安い撃墜であり、次回の監査も速くなる
一方が仕様を書き、他方がそれを監査する
ミューテーションテストは「よりよい TDD」ではなく、その代替でもありません。テストも設計も意図の言明も生みません。生むのは、あなたの仕様が想定より鈍感になっている場所の一覧であり、それは本当に価値があり、そして本当に別物です。
この役割分担は、両方の仕事が同じ技術者のものだった時代よりも今のほうが重要です。テストは依然として TDD によって、振る舞いについての肯定的な主張として生まれるべきです。エージェントが書く場合も同じで、だからこそ「もっともらしい誤実装なら失敗するテストを書け」という指示はプロンプトに入るべきものです。そのうえでミューテーション実行が、議論の通じない別コンテキストからその主張を監査します。そして生存者を直接テストにしないこと。まず要求へ翻訳するか、それが住むコードを削除する。否定的な発見が持続する価値になるのは、誰かがそれを「ソフトウェアが何のためにあるか」という肯定的な言明に変えたときだけです。
関連するエンジニアリング記事
カバレッジと複雑度には専用のメトリクスがあり、どちらの実践も実際に動いているエージェント群では専任ロールに割り当てられています。
よくある質問
ミューテーションテストはコードカバレッジより優れていますか?
より強い問いに、はるかに高い代価で答えます。カバレッジは行が実行されたかを数え、ミューテーションは何かが実際に検証されたかを問います。安価な常設ゲートとしてカバレッジを使い、重要なコードにはミューテーションテストを定期的な監査として当て、エージェントが書いたものには常設のチェックとして当ててください。
AI 生成コードにミューテーションテストはどう効きますか?
レビューが取り逃がす特定の失敗を捕まえます。実装と同じ誤解から生成されたテストが、何も検査しないまま通り、優秀なカバレッジを示す、という失敗です。ミューテーションはテストの存在を気にせず、コードが変わったときに反応するかだけを問うので、実装を鏡写しにしたスイートは即座に露呈します。
エージェント自身にミューテーションテストを走らせるべきですか?
はい。ただしコードを書いた工程とは別工程で、生存者は最大化すべきスコアではなく問いとして扱わせること。生存者の報告と要求レベルのテストの提案までをエージェントに任せ、「要求とは何か」の決定は人間か仕様ロールに残してください。さもないと機械速度で変更検知器が量産されます。
ミューテーションスコアはどれくらいを目標にすべきですか?
おおむね 80% を超えると強い、60〜80% は実際の穴を抱えつつ使えると見なされるのが一般的ですが、その数値が意味を持つのはモジュール単位です。より有用な指針は「変更ファイルで後退させない」ことと、スコアの低下を止める break しきい値です。
チームはどのツールを使っていますか?
JVM では PIT が定番、Stryker Mutator は JavaScript、TypeScript、C#、Scala をカバーし、Python では mutmut と cosmic-ray が一般的な選択肢です。いずれも実行を変更ファイルに絞れ、それがプルリクエスト単位でこの実践を手頃にしています。
CI のどこに置くべきですか?
プルリクエストでは増分実行で、テスト単位のカバレッジ解析を使い変更ファイルに限定し、加えてスケジュールで全体スイープを回します。生存者はビルド失敗ではなくレビュー項目として報告し、ゲートはスコアの後退だけに掛けてください。