あなたのミューテーションテストレポートは生存者でいっぱいで、そのうち少なくとも1つはあなたにとって意味不明だ。
ツールは、47行目で>を>=に反転させたか、条件ブロック全体をtrueに置き換えたか、あるいはテストされていることすら知らなかった文字列リテラルを変異させたと言っている。diffを3回読んだ。それでも、ミュータントがどのような挙動を壊したのか、どのテストがそれを捕捉できるのかがわからない。だから飛ばす。ミュータントは生き残る。スコアは低いままだ。
これがミューテーションテストの導入が停滞する最も一般的な理由だ。ランタイムの問題でもなければ、等価ミュータントの問題でもない。エンジニアが生存者を見つめ、それを欠落しているテストに結びつけられず、ミューテーションテストは単なるノイズだと判断する瞬間だ。
そうではない。必要なのは、違う出発点だけだ。
問題:ミューテーションから始めているのではなく、コードから始めるべきだ
ほとんどの開発者は、生存ミュータントへの対処を逆さまに行っている。彼らはミューテーションのdiffを読み、どのような合成バグが導入されたのかを理解しようとし、次にその特定のバグを捕捉できるテストを思いつこうとする。
これは明白なケースでは機能する。しかし、微妙なものには失敗する。
ミューテーションは、3階層深いヘルパー関数の中にあるかもしれない。あなたが存在すら知らなかった副作用に影響を与えるかもしれない。生成されたコードやフレームワークのコールバックの中にあるかもしれない。diffは何が変わったかを示すが、既存のテストがなぜ気にしなかったかは示さない。ミューテーションのデコードから始めると、合成コードに対するリバースエンジニアリングをしていることになる。これは経験豊富なエンジニアにとっても難しい。
より良いアプローチは、ミューテーションを完全に無視し、生存者を合成バグについての信号ではなく、あなたのコードについての信号として扱うことだ。
生存ミュータントは、テストが検証していない行に過ぎない
すべての生存ミュータントは、テスト中に実行されたが、その出力や副作用が決してアサートされなかったコードの行を指している。
ミューテーションは何でもありえた。それが生き残ったという事実は1つのことを意味する:その行が間違った結果を出しても、あなたのテストは依然として通過するということだ。それを修正するために、特定のミューテーションを理解する必要はない。その行が何をすべきかを理解し、それが実行されたかどうかをチェックするテストを書く必要がある。
この再定義により、問題は合成diffのリバースエンジニアリングから、通常のテスト設計へと変わる。
方法:ミューテーションから順方向に進むのではなく、行から逆方向にたどる
どんなにdiffが混乱して見えようと、あらゆる生存ミュータントに機能する4ステップのプロセスを紹介する。
ステップ1:ミューテーションが触れた正確な行を見つける
ミューテーションテストツールのHTMLレポートは、ソースコードとインラインで変異した行を表示する。そのファイルを開き、diffではなく元の行を見つける。
例えば、Strykerがこの関数で生存者を報告したとしよう:
// pricing.js
function calculateDiscount(price, customer) {
if (customer.loyaltyYears > 5) {
return price * 0.85;
}
if (customer.isStudent) {
return price * 0.90;
}
return price;
}
module.exports = { calculateDiscount };
ミューテーションは最初の条件で>を>=に変更した。これはあなたを混乱させるかもしれない詳細だ。今は忘れておこう。行はif (customer.loyaltyYears > 5)だ。
ステップ2:この行が何を強制すべきか問う
ミューテーションについて考えるな。ビジネスルールについて考えろ。
この行は、顧客が5年以上忠実であったかどうかをチェックすべきだ。真なら、彼らは15%の割引を受ける。境界が重要だ。ちょうど5年の顧客はこの割引を受けるべきではない。6年の顧客は受けるべきだ。
既存のテストを見てみよう:
// pricing.test.js
const { calculateDiscount } = require('./pricing');
test('returns full price for new customers', () => {
expect(calculateDiscount(100, { loyaltyYears: 0 })).toBe(100);
});
test('gives loyalty discount to long-term customers', () => {
expect(calculateDiscount(100, { loyaltyYears: 6 })).toBe(85);
});
test('gives student discount to students', () => {
expect(calculateDiscount(100, { isStudent: true })).toBe(90);
});
テストは最初のif文の両方の分岐をカバーしている。しかし、境界はテストしていない。loyaltyYears: 5は一度も現れない。これが>=ミュータントが生き残った理由だ。ツールはあなたがそこにあることを知らなかった隙間を見つけた。
ステップ3:この行が間違っていたら失敗するテストを書く
この特定のミューテーションを殺すテストを書く必要はない。ビジネスルールが違反された場合に失敗するテストを書く必要がある。
// pricing.test.js
test('does not give loyalty discount at exactly 5 years', () => {
expect(calculateDiscount(100, { loyaltyYears: 5 })).toBe(100);
});
test('gives loyalty discount at 6 years', () => {
expect(calculateDiscount(100, { loyaltyYears: 6 })).toBe(85);
});
これで境界は明確になった。誰かが>を>=に変更すると、ちょうど5年の顧客が誤って割引を受けることになるため、最初のテストが失敗する。ミュータントは死ぬ。合成diffにおける>=の意味を理解する必要はまったくなかった。
ステップ4:ミューテーションテストを再度実行して確認する
このファイルだけでミューテーションツールを実行するか、我慢強いならフルスイートを実行する。生存者は消えているはずだ。消えていない場合、あなたのテストは実際には思っている行を実行していない。カバレッジデータを確認して確かめよう。
行自体が混乱する場合
時々、変異した行はライブラリラッパー、フレームワークフック、あるいはあなたが書いていない生成コードの中にある。そういった場合、生存者は違うことを教えてくれている:あなたのコードベースには、人間がテストできるほど十分に理解していないコードがある。
これはミューテーションテストの問題ではない。これはミューテーションテストが浮き彫りにしたコード品質の問題だ。
選択肢は、ミューテーションテストがなくても同じだ:テスト可能な表面を持つまでコードをリファクタリングするか、このコードがテストされていないことを受け入れてそのようにマークするかだ。一部のツールでは、特定の行やファイルを無視できる。その力は控えめに使おう。無視されたすべてのミュータントは、出荷される可能性のあるバグだ。
難しいケース:副作用を変えるミューテーション
境界チェックは簡単だ。副作用は難しい。
この関数を考えてみよう:
// logger.js
function logError(error, context) {
const timestamp = new Date().toISOString();
console.error(`[${timestamp}] ${context}: ${error.message}`);
metrics.increment('error.count');
}
module.exports = { logError };
ミューテーションテストツールは、console.error呼び出し全体を何もないものに置き換えたり、文字列テンプレートを空文字列に置き換えたりするかもしれない。あなたのテストがログ出力を検証しない場合、これらのミュータントは生き残る。
ほとんどのチームはログのテストをしない。それは通常問題ない。しかし、ログがアラートシステムによって消費される場合、あるいはmetrics.incrementがオンコールをページングするダッシュボードを駆動する場合、これらのテストを飛ばすことはリスクだ。
アプローチは同じだ。ミューテーションを研究するな。この行が生み出すべき挙動は何か問う。答えが「タイムスタンプ付きの構造化ログエントリ」なら、ログ出力をアサートするテストを書こう:
// logger.test.js
const { logError } = require('./logger');
test('logs error with timestamp and context', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
logError(new Error('db timeout'), 'payment-service');
expect(spy).toHaveBeenCalledWith(
expect.stringMatching(/\d{4}-\d{2}-\d{2}T.*payment-service.*db timeout/)
);
spy.mockRestore();
});
console.error呼び出しを削除するミュータントは、スパイが呼び出しを検出しないため、今や失敗する。文字列テンプレートを破損させるミュータントは、正規表現がマッチしないため失敗する。どちらのミューテーションも理解する必要はなかった。
なぜこのアプローチはミューテーションを研究するよりスケールするのか
可能なミューテーションは無限にある。あなたのコードが持つべき挙動の量は有限だ。
特定のミューテーションを殺すテストを書こうとすれば、合成バグとのモグラたたきゲームをしていることになる。コードの実際の挙動を検証するテストを書けば、ミューテーションは副作用として死ぬ。2つ目のアプローチは持続可能だ。1つ目はそうではない。
これはまた、ミューテーションツールに密結合しすぎたテストを書くのを避ける方法でもある。47行目で>が使われていることをアサートするテストは脆弱だ。5年の顧客が正規価格を支払うことをアサートするテストは正しい。
限界:等価ミュータントは依然として存在する
この方法は等価ミュータントには役立たない。なぜなら、等価ミュータントは欠落しているテストを表していないからだ。彼らは同一の挙動を生み出す変換を表している。
ミューテーションが可換演算でa + bをb + aに変更しても、それを殺せるテストは存在しない。アサートすべき欠落している挙動はない。これらは偽陽性であり、すべてのミューテーションテストツールが持っている。それらを認識し、無視し、先に進むことを学ぼう。2%の等価ミュータントのノイズフロアが、残りの98%もノイズだと信じさせないようにしよう。
最悪の3ファイルから始める
ミューテーションスコアが低く、数十の生存者がいる場合、すべてを理解しようとするな。生存者が最も多い3ファイルを選ぶ。各ファイルについて、最も怪しい3行を選ぶ。この方法をそれぞれに適用する。
1時間以内に、コードベースをより正確にする9つのテストを書いているだろう。ミューテーションテストを再実行する。スコアは跳ね上がる。さらに重要なことに、以前よりも自分のコードをより深く理解しているだろう。
ミュータントは、あなたに自分たちを理解してほしいと求めているのではない。自分のコードを理解してほしいと求めているのだ。
FAQ
テストを書くためにミューテーションオペレータを理解する必要はあるか? ない。ミューテーションオペレータは気を散らすものだ。元の行が何をすべきかに集中しろ。その挙動のためのテストを書け。ミュータントは副作用として死ぬ。
変異した行が直接テストできないプライベート関数の中にある場合はどうすればよいか? それは設計の信号だ。関数にテストに値する挙動があるなら、それはテスト可能であるべきだ。テストのために公開するか、それを呼び出すパブリックAPIを通じてテストするかだ。パブリックAPIのテストがその挙動に到達できない場合、その挙動はデッドコードかもしれない。
すべての生存ミュータントを殺すべきか? いいえ。一部のミュータントはログ、メトリクス、あるいはその他の可観測性コードに触れ、テストのコストが価値を上回る。自分のコードベースにとって意味のある閾値を設定し、エネルギーをビジネスロジックのミュータントに集中させよう。
テストがミュータントを殺すが、それでも違和感がある場合はどうすればよいか? その感覚を信じろ。偶然ミュータントを殺すが、明確にビジネスルールをアサートしないテストは技術的負債だ。テスト言語ではなく、ドメイン言語で期待される挙動を表現するように書き直そう。