テスト設計

ミューテーションテストは否定的。TDD のテストは肯定的。

どちらもテストと呼ばれますが、主張の向きは正反対です。TDD のテストはシステムが何をすべきかを述べます。ミューテーション実行が報告できるのは、テストスイートが気づかなかったことだけです。どちらの符号を手にしているかで、結果への対処が変わります。

2 種類の主張

テスト駆動のテストは、振る舞いが存在する前に書かれます。まず失敗し、次に通り、それ以降は常設の言明になります。この入力はこの結果を生まなければならない、という言明です。スイートは読める仕様として積み上がり、先に書くという行為が設計に圧をかけます。呼び出しにくいコードはテストしにくいからです。ミューテーション実行は構造的に別のことをします。すでに動くコードを取り、意図的に書き換え、スイートを再実行します。結果はいつも否定形です。この改変は気づかれなかった、と。その出力のどこにも、ソフトウェアが何のためにあるのかは書かれていません。

緑の TDD スイートは意図についての言明の集合です。高いミューテーションスコアは、スイートに対する特定種類の反証が「存在しない」ことにすぎません。仕様と呼べるのは、そのうち片方だけです。

肯定的なテスト

  • + コードより先に書かれるため、要求はロジックになる前に言葉として存在する
  • + 最初に失敗する。テストがそもそも失敗しうることを示す、唯一安価な証明である
  • + 規則に名前を与えるのでリファクタリングを生き延びる。内部は動いてもアサーションは動かない
  • + 設計がまだ安く変えられるうちに、設計へ圧をかける
  • + 次の人が、そして次のエージェントがドキュメントとして読める

否定的なチェック

  • 事後に走る。すでに存在するコードとテストに対して
  • 殺されたミュータントは新しい情報を伝えない。信号を運ぶのは生存者だけ
  • 生存者は盲点の証拠であり、製品の欠陥の証拠には決してならない
  • 欠けている要求については何も言わず、試した変更への鈍感さだけを言う
  • レポートとタスク一覧を生むが、誰かが保管する成果物は生まない

同じ言葉、異なる 2 つの計器

肯定的なテスト 否定的なチェック
何を主張するか システムはこう振る舞わなければならない。 あなたのスイートはこの変更に気づかなかった。
緑が証明すること これまでに仕様化した振る舞いはすべて実装されている。 その演算子集合に対して感度がある。正しさについては何も言わない。
後に残るもの テストと、書かされたことで形づけられた設計。 レポート。価値は失効する前に抜き出さなければならない。
いつ走るか コードを書いている間、分単位で継続的に。 CI で。変更ファイルへの増分実行か、夜間の全体スイープ。
コストの構造 着実に支払われ、開発に織り込まれる。 ミュータント数 × 関連テストの実行時間。双方に比例して増える。
設計への影響 テスト容易性への圧。扱いにくい設計はすぐ痛む。 なし。すでにあるものを採点するだけ。
失敗の届き方 赤。意図的に、あなたが書き、1 歩ずつ。 誰も書いていない生存者。いまから解釈しなければならない。

同じ生存者、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 }
}

否定が毒に変わるところ

スコアを追いかける

ミューテーションスコアが目標になった瞬間から、要求を述べるためではなくミュータントを殺すためのテストが書かれます。緑だからレビューを通り、振る舞いが何も変わっていない次のリファクタリングで壊れるのは、まさにそれらです。

等価ミュータントと戦う

観測可能な振る舞いを変えずにコードだけを変える変異があります。誠実なテストでは殺せません。記録し、除外し、先へ進んでください。ここに費やす時間が買うのは数値であって、確信ではありません。

規則ではなく変異を主張する

新しいテストが守る要求に名前を付けられないなら、それは変更検知器です。将来の編集すべてを失敗として報告し、チームにテスト出力を読まない習慣を教え込みます。

毎回すべてを走らせる

大きなコードベースの全体スイープは夜間ジョブであり、プルリクエストのゲートではありません。PR の実行はテスト単位のカバレッジ解析で変更ファイルに絞り、網羅的な実行はクリティカルパスの外に置いてください。

すべての生存者を肯定的な言明に変える

生存者を問いとして読む

  • - この変更が何かを壊すためには、どんな振る舞いが真でなければならないか?
  • - 誰かが書き留めていたなら、いまどの要求が失敗しているのか?
  • - この変異が本番に出たら、下流の誰が気づくのか?

撃墜ではなく答えを書く

  • - テストはミュータントや行番号ではなく、規則にちなんで名づける
  • - 公開面を通してアサートし、内部が変えられる自由を残す
  • - 内部を露出させないと規則に届かないなら、それはテストではなく設計の発見である

あるいはコードを削除する

  • - 名前を付けられる要求がないなら、その振る舞いには実際に何も依存していない
  • - 仕様のないコードの生存者は、欠けたテストよりも死んだ要求であることが多い
  • - 削除は最も安い撃墜であり、次回の監査も速くなる

一方が仕様を書き、他方がそれを監査する

ミューテーションテストは「よりよい TDD」ではなく、その代替でもありません。テストも設計も意図の言明も生みません。生むのは、あなたの仕様が想定より鈍感になっている場所の一覧であり、それは本当に価値があり、そして本当に別物です。

だから役割分担は明示しておきましょう。テストは TDD によって、振る舞いについての肯定的な主張として生まれます。ミューテーション実行はその主張の監査として定期的に行います。そして生存者を直接テストにしないこと。まず要求へ翻訳するか、それが住むコードを削除する。否定的な発見が持続する価値になるのは、誰かがそれを「ソフトウェアが何のためにあるか」という肯定的な言明に変えたときだけです。

よくある質問

ミューテーションテストはコードカバレッジより優れていますか?

より強い問いに、はるかに高い代価で答えます。カバレッジは行が実行されたかを数え、ミューテーションは何かが実際に検証されたかを問います。安価な常設ゲートとしてカバレッジを使い、重要なコードにはミューテーションテストを定期的な監査として当ててください。

ミューテーションスコアはどれくらいを目標にすべきですか?

おおむね 80% を超えると強い、60〜80% は実際の穴を抱えつつ使えると見なされるのが一般的ですが、その数値が意味を持つのはモジュール単位です。より有用な指針は「変更ファイルで後退させない」ことと、スコアの低下を止める break しきい値です。

ミューテーションテストは TDD を置き換えますか?

置き換えません。ミューテーションテストは何も書かず、何も仕様化しません。すでにあるスイートを採点するだけで、どの発見にも「どの要求を含意するのか」という人間の判断が必要です。

チームはどのツールを使っていますか?

JVM では PIT が定番、Stryker Mutator は JavaScript、TypeScript、C#、Scala をカバーし、Python では mutmut と cosmic-ray が一般的な選択肢です。いずれも実行を変更ファイルに絞れ、それがこの実践を手頃にしています。

CI のどこに置くべきですか?

プルリクエストでは増分実行で、テスト単位のカバレッジ解析を使い変更ファイルに限定し、加えてスケジュールで全体スイープを回します。生存者はビルド失敗ではなくレビュー項目として報告し、ゲートはスコアの後退だけに掛けてください。

© 2026 - Ryware.