テストは通る。カバレッジレポートは87%だ。しかしミューテーションスコアは40%で、半数のミュータントがまだ生きている。
その40%は、コードが壊れているという意味ではない。テストが壊れているという意味だ。カバレッジはテスト実行中にどの行が実行されたかを測定する。ミューテーションテストは、それらの行が間違った動作をし始めた場合にテストが気づくだろうかどうかを測定する。ミューテーションスコア40%は、コードに導入されうるバグの60%がそのままCIを通過してしまうことを意味する。
生存ミュータントの正体
生存ミュータントとは、テストが捉え損ねた小さな人工的なバグだ。
ミューテーションテストツールは、ソースコードを取り、定義済みの変換セットを一度に一つずつ適用することで動作する。> を >= に反転したり、+ を - に変えたり、ブール条件を true に置き換えたりするかもしれない。コードの変換された各バージョンがミュータントだ。ツールはすべてのミュータントに対してテストスイートを実行する。いずれかのテストが失敗すれば、ミュータントは「キル」される。すべてのテストが通過すれば、ミュータントは「生存」する。
生存ミュータントは、二者択一を意味する。テストが実際にはミュータントが壊した動作を検証していないか、ミュータントが「等価」(変換が意味的に同一のコードを生成する。これはミューテーションテストにおける既知の難問だ)であるかのどちらかだ。
大多数の生存者は等価ではない。大多数は歩く死んだバグだ。
具体例:パスワードバリデーター
パスワードがポリシー要件を満たしているかどうかをチェックする関数だ:
// password.js
function isValidPassword(password) {
if (password.length < 8) {
return false;
}
if (!/[A-Z]/.test(password)) {
return false;
}
if (!/[0-9]/.test(password)) {
return false;
}
return true;
}
module.exports = { isValidPassword };
そしてこれは100%の行カバレッジを与えるテストスイートだ:
// password.test.js
const { isValidPassword } = require('./password');
test('accepts a valid password', () => {
expect(isValidPassword('Hello1')).toBe(true);
});
test('rejects a short password', () => {
expect(isValidPassword('Hi1')).toBe(false);
});
test('rejects a password without uppercase', () => {
expect(isValidPassword('hello1')).toBe(false);
});
test('rejects a password without a digit', () => {
expect(isValidPassword('Hellooo')).toBe(false);
});
待てよ。isValidPassword('Hello1') は true を返すが、'Hello1' は6文字しかない。最初のチェックは拒否すべきだ。テストは間違っているが、テスト自体が誤った動作をアサートしているため通過してしまう。
Strykerのようなミューテーションテストツールはこれを捉える。長さチェックで < を <= に反転するミューテーションがその一つだ。そのミュータントは生存するだろう。なぜなら、既存のテストは実際には8文字の境界を検証していないからだ。別のミューテーションでは、最初の if ブロック全体を削除するかもしれない。そのミュータントも生存するだろう。なぜなら、テストには大文字や数字なしの8文字のパスワードが含まれていないからだ。長さの上限は、他のルールと組み合わせてテストされることはない。
実際にこれらのミュータントをキルするテストスイートだ:
// password.test.js
const { isValidPassword } = require('./password');
test('rejects password shorter than 8 chars', () => {
expect(isValidPassword('Hello1')).toBe(false);
});
test('accepts password exactly 8 chars with uppercase and digit', () => {
expect(isValidPassword('Hello1!@')).toBe(true);
});
test('rejects password without uppercase', () => {
expect(isValidPassword('hello1!@')).toBe(false);
});
test('rejects password without digit', () => {
expect(isValidPassword('Helloooo')).toBe(false);
});
test('rejects password missing both uppercase and digit', () => {
expect(isValidPassword('helloooo')).toBe(false);
});
これで8の境界が明示的にテストされる。<= のミュータントは失敗する。なぜなら 'Hello1!@'(8文字)は受け入れられなければならないからだ。削除ミュータントは失敗する。なぜなら長さチェックがなければ 'helloooo' が通過してしまうからだ。
ミューテーションテストの内部動作
ミューテーションテストは計算コストが高い。なぜなら、ミュータントごとに完全なテストスイートを実行するからだ。
コードベースが10,000行でミューテーションツールが3,000のミュータントを生成すれば、それは3,000回のテストスイート実行だ。このため、初期の学術的実装は実際のコードベースでは基本的に使い物にならなかった。現代のツールは賢くなっている。
JavaScriptおよびTypeScriptで最も広く使われているミューテーションテストフレームワークであるStrykerは、いくつかの最適化を使用する:
-
ミュータントスコーピング:Strykerは、初期のドライランからのカバレッジデータに基づいて、ミューテートされた行に到達しうるテストのサブセットのみを実行する。
-
並列実行:ミュータントはワーカープロセス間で評価される。
-
インクリメンタルモード:Strykerは結果をキャッシュし、前回の実行以降に変更されたコードのミュータントのみを再評価する。
-
チェッカー:コンパイル言語では、Strykerはプロジェクト全体を再コンパイルせずにASTレベルでミュータントを検証できる。
これらの最適化があっても、大規模なコードベースでの完全なミューテーションテストの実行には依然として10〜30分かかることがある。そのため、ほとんどのチームは、保存のたびではなく、プルリクエストやナイトリービルドでCI上でミューテーションテストを実行する。
誰も語らないトレードオフ
ミューテーションテストは無料ではなく、常に適切なツールでもない。
等価ミュータント問題は最大の理論的限界だ。一部のミューテーションは観測可能な動作を変えない。考えてみよう:
const timeout = 1000 * 60;
これを 1000 * 61 に変えるミューテーションは意味的に異なる。しかし 60 * 1000 に変えるミューテーションは等価だ。値が同一なので、いかなるテストもキルできない。等価ミュータントを本物の生存者と区別することは、一般的な場合には決定不能だ。現代のツールは、明らかなケースをスキップするためにヒューリスティクスを使用するが、それでもいくつか見かけるだろう。
パフォーマンスは現実だ。 中規模のTypeScriptプロジェクトでは、Strykerが2,000のミュータントを生成し、それらを評価するのに15分かかるかもしれない。プルリクエストで有効にすれば、それは実行ごとに15分のCI時間だ。チームは通常、しきい値(例えば、ミューテーションスコアが60%を下回ったらビルドを失敗させる)から始め、完全な分析を毎晩実行する。
過大な自信は両刃の剣だ。 ミューテーションスコア100%は、コードにバグがないという意味ではない。ツールのミューテーションオペレータにマッチするバグが通過しなかったという意味だ。ミューテーションテストは、作成方法を知らないバグを発明することはできない。要件の論理エラー、シミュレートできない競合状態、サービス境界をまたぐ統合失敗は捉えられない。
ミューテーションテストを実際に始める方法
JavaScriptやTypeScriptを書いているなら、Strykerが出発点だ。
インストールする:
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner
stryker.config.mjs を作成する:
// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
packageManager: 'npm',
reporters: ['html', 'clear-text', 'progress'],
testRunner: 'jest',
coverageAnalysis: 'perTest',
mutate: ['src/**/*.js'],
threshold: {
break: 60,
},
};
export default config;
実行する:
npx stryker run
スコアではなく、HTMLレポートを見ることから始める。レポートは、各生存ミュータントをソースコードとインラインで表示する。最初の10個の生存者を読み込む。それぞれについて尋ねる:この場所に実際のバグがあれば本番問題を引き起こすだろうか? もしそうなら、それを捉えるテストを書く。そうでなければ、コードが過剰設計かどうかを考える。
100%を追いかけるな。成熟したコードベースでは、70〜80%が強いスコアだ。50%を下回ると、コードを実行しても意味のあるアサーションを何も行っていないテストがあるだろう。90%を超えると、報酬逓減と増大する等価ミュータントの税にぶつかっている可能性が高い。
40%に対してすべきこと
ミューテーションスコア40%は贈り物だ。テストがどこで飾りになっているか正確に教えてくれる。
最も生存ミュータントが多い3つのファイルを選ぶ。各生存者を読み、どのアサーションが欠けているか尋ねる。修正はしばしば単純だ:テストで関数を呼んだが戻り値をチェックしなかった。あるいはデータをパーサーに通したが解析済みの出力を検証しなかった。あるいは異なる入力でハッピーパスを3回テストしたが、エラーブランチはテストしなかった。
ミュータントはノイズではない。それらは、テストされていないバグが隠れている可能性が最も高い場所のランキングリストだ。上から始める。
よくある質問
コードカバレッジとミューテーションテストの違いは何ですか? コードカバレッジはどの行が実行されたかを測定する。ミューテーションテストは、それらの行にバグが含まれていた場合にテストが失敗するかどうかを測定する。カバレッジ100%でミューテーションスコア40%は、すべての行を実行したが、ほとんどの行が間違っていてもテストが気づかなかったことを意味する。
ミューテーションテストは既存のコードのバグを見つけられますか? いいえ。ミューテーションテストはソースコードではなくテストを評価する。テストが不十分な場所を教えてくれる。コードが正しいかどうかは教えてくれない。テストが特定のクラスのエラーを捉えられるかどうかだけを教えてくれる。
どの言語に優れたミューテーションテストツールがありますか? JavaScript/TypeScript(Stryker)、Java(PIT)、C#(Stryker.NET)、Python(mutmut)、Rust(cargo-mutants)にはすべて成熟したツールがある。エコシステムはパフォーマンスとサポートされているミューテーションオペレータで異なる。
ミューテーションテストはコードカバレッジを置き換えるべきですか? いいえ。カバレッジは安価で高速だ。開発中の迅速なフィードバックのために使用する。ミューテーションテストは、カバレッジが見えない盲点を見つけるための定期的な品質ゲートとして使用する。