テストの範囲や深さをどこまでカバーするべきかは開発者にとって常に難しい課題です。完全なテストを書くことは困難であり、バランスを見つけることが求められます。 この記事では、テストケースの品質を担保する手法としてMutation Testingを紹介します。
Advent Calendarもやってるので読んでみていただけると嬉しいです。
Mutation Testingとは
Mutation Testingは、Fuzzing Testの1つで、コード内にミュータントと呼ばれる「人工的な誤りを含むプログラム」を加えそれに対するテストの反応を評価する手法です。評価はテストケースをすり抜け生き残ったミューテーションが少ないほど、テストの品質が高いと判断されます。 この手法によりテストの網羅性や品質を客観的に確認できます。
Mutation Testingの例
文章の説明だけだとわかりにくいと思うので、単純なプログラムとそれに対するテストケースを例としてMutation Testingを説明しようと思います。
// テスト対象 class Calculator { add(a, b) { return a + b; } subtract(a, b) { return a - b; } } // テストケース const assert = require('assert'); describe('Calculator', () => { it('should add two numbers', () => { const calculator = new Calculator(); assert.strictEqual(calculator.add(2, 3), 5); }); it('should subtract two numbers', () => { const calculator = new Calculator(); assert.strictEqual(calculator.subtract(5, 3), 2); }); });
次にミュータントを加えてテストが失敗することを確認します。
ミュータントは + が * が変更されている
です。
// テスト対象を書き換え(ミュータント) class Calculator { // NOTE: 下記の算術演算子が変わった add(a, b) { return a * b; } subtract(a, b) { return a - b; } }
この変更により、通常の加算が乗算に変わるため、テストケースが正しく定義されていればテストが失敗するはずです。 逆に下記のようにテストケースが成功してしまう場合は、考慮不足ということになります。
// テストケース const assert = require('assert'); describe('Calculator', () => { // NOTE: 算術演算子が * に変わってもテストが成功してしまう。テストを修正する必要あり。 it('should add two numbers', () => { const calculator = new Calculator(); assert.ok(calculator.add(2, 3) > 0); }); // ... });
このようにMutation Testingを利用することで、変更に対してテストが期待通りの動作をすることを確認できます。
Mutation Testingの種類
Mutation Testingは基本的に3種類があります。
1. Value Mutation
Value Mutationでは、定数の値 / メソッドで渡されるパラメーター / ループで使用される値を変更して、ミュータントなプログラムを作成します。大きい値が小さい値に変更されるか、またはその逆になります。基本的には、プログラム内ですでに定義されている値を変更して値の変更を行います。
// 変更前 let a = 75636737; let b = 3454; let mult = a * b; console.log(mult); // 変更後(ミュータント) let a = 75; let b = 345466465; let mult = a * b; console.log(mult);
2. Decision Mutation
Decision Mutationでは、プログラムで使用される論理演算子と算術演算子が変更され、プログラム内の全体的な意思決定とその結果が変わります。たとえば、特定の「if」ステートメントは (a > b) の場合にのみ実行されます。変更後のコードでは、この演算子が (a < b) に変更され、コードの判定が変更されます。 前述の実装例もこのMutation Testingになります。
// 変更前 if (a > b || b > c) { console.log("yes"); } else { console.log("No"); } // 変更後(ミュータント) if (a < b || b < c) { console.log("yes"); } else { console.log("No"); }
3. Statement Mutation
Statement Mutationでは、ミュータントを作成するために、コードのステートメント全体に変更が加えられます。ステートメントの変更には、ステートメント全体の削除、コード内のステートメントの順序の変更、コード内の別の場所へのステートメントのCopy&Paste、元のコード内のいくつかのステートメントの繰り返しまたは複製などが含まれます。
// 変更前 if (a > b) { console.log("a is greater"); } else { console.log("b is greater"); } // 変更後(ミュータント) if (a > b) { // removing the statement } else { console.log("b is greater"); }
Mutation Testingの実践
ミュータントの生成は専用のツールやフレームワークを使用して行われます。 JavaScript/TypeScript向けのMutation TestingツールであるStrykerを例に説明します。
シンプルに実行するのであれば、Packageの追加し導入ガイドに沿って質問に回答などすれば良きように設定が完了します。
具体の設定は stryker.config.mjs
として生成されており、オプションなども気になる方はこちらを確認してみてください。
後は stryker run
を実行すれば、ターミナルへの実行結果表示とhtmlのレポート生成が行われます。
StrykerからPlaygroundも提供されているので、実際に動かしてみたい方はそれを触ってみてください。
他の言語でもMutation Testingをやってみたいという方は、以下のrepositoryにツールがまとめられているので見てみると良さそうです。
Mutation Testingのpros/cons
実装を検討する際の参考として以下にpros/consをまとめました。
pros
- プロダクトの信頼性向上: エッジケースなども機械的に洗い出すことができるので、通常のテストでは見逃される可能性のあるエラーを検出できます。
- テストの品質向上: Mutation Testingを活用することで、テストケースの品質が向上します。不足しているテストを特定し、それを補完することでより堅牢なアプリケーションを構築できます。
cons
- 実行時間の増加: Mutation Testingは多くの変異を生成し、それぞれに対してテストを実行するため、通常のテストよりもかなり実行時間が増加します。大規模なプロジェクトでは時間的な制約が生じる可能性があります。そのためどのように運用に組み込むかやどのコードを対象にするかはチームで検討が必要かなと思います。
- ブラックボックステスト: ソースコードの変更が含まれるため、ブラックボックステストには使用できません。
- 範囲の広いMutation Testingはテストケースが膨大になる: 多くの異なる変異プログラム(ミュータント)が生成された場合、それらすべてのミュータントに対して元のテストスイートを実行する必要があり、その作業は実行するのは非常に労力がかかります。
結論
Mutation Testingは、テストの品質を向上させるための効果的な手法であり、プロジェクトに組み込むことで信頼性の高いコードを構築できます。テスト品質管理の難題に立ち向かう開発者にとって新たな視点を提供します。
一方、consの部分であげたような点については、ミュータントの数を制限したり効率的なテストケースの選択を行ったりする工夫が必要かなと思います。
参考
文責: 南光