ミューテーションテストの開発組み込みを考える
前回の記事でJavaのミューテーションテストツールPIT(PiTest)を紹介しました[1]。ミューテーションテストはテストの質を評価するうえで有望な技術ですし、PITを利用すれば特に設定を作り込むことなく簡便なテストの評価ができます。
しかし、機械的に変異を組み込むアプローチであるがために、日々の開発で利用するならば検討したほうが良い点もあります。引き続き以前の記事で利用してきた「よくあるサンプルアプリ」を題材にして、どのような理想が望ましいかを検討してみます。
この記事のコードサンプルは、Gitlab リポジトリ にありますので、興味がある方はあわせてご利用下さい。
開発の想定シナリオ
#リリース前にテストの十分性を評価するために「よくあるサンプルアプリ」に対してPiTestを利用してミューテーションテスト(解析)を行い、不十分だったテストを補強することができました。リリース前にテスト不備を検出できたのは良いこととして、最後にテスト不十分でバタバタするのは心臓によろしくありません。日々の開発の中でテスト不足があれば検出し、安心してリリースに臨みたいところです。
実のところ、PiTestの実行には数十秒の時間がかかっていました。実行されるテスト数も少ない開発の初期段階でこの所要時間とするならばこのまま使い続けるのは危険[2]です。そこでPiTestの設定を見直して、日々の開発で無理なく利用できるかを検討することにしました。
テストの実行時間の内訳
#前回の記事でも紹介しましたが、ミューテーションテストはテスト対象を機械的に改変してテストを実行するアプローチです。つまり、単純に考えるならば[3]「改変を入れられる箇所」、「加える変異内容」、「(もとの)テストの実行時間」が乗数的に作用したものが合計の実行時間になります。
実行時のログを見ても(改変を行う準備である)カバレッジや依存関係の解析「coverage and dependency analysis」と、各ミューテーションに対するテスト実行・結果の解析「run mutation analysis」が処理時間のほとんどを占めています。つまり、改変対象コード、改変のパターン、実行されるテストコードのそれぞれを無駄のないように調整し、必要十分なミューテーション解析を行う設定ができれば所要時間を短縮できる可能性があります。
改変対象コードの絞り込み
#PiTestはテスト対象をコンパイルしたバイトコードに対して一定の改変を行う「ミューテーター」を作用させることでテスト対象の振舞いを変更し、変更後の「改変された振舞い」がテストの失敗という形で検出されるかを評価します。つまり、変更がないソースコード(プロダクトコード/テストコード)に対して再度実行した場合には同じ結果が得られると想定[4]されます。
つまり、ミューテーションテストは「前回のミューテーションテスト結果」から変更があったソースコードのみを対象に実行できれば効率よくフィードバックを得られる技術と言えます。そのような利用に対しPiTestにはインクリメンタル解析や、対象を指定した解析実施の機能提供があります。
インクリメンタル解析
#執筆時点で試験的(experimental)ではありますが、PiTestにはインクリメンタル解析というコードの変化点(増分)に対してミューテーションテストを実施する機能の提供があります。
インクリメンタル解析は、ミューテーションテストの実行時に対象ソースコード(プロダクトコード/テストコード)の情報を保存します。次回の実行時にこの情報を入力とすることで、「前回実行時からの変更」が無いものについては結果が変わらないものと推定し、解析の対象から除外します。
テスト対象の振舞いは主として操作されるソースコード(コンパイルされたバイトコード)によって制御されますが、ソースコードが変更されなくても依存先の変更に由来してテスト対象の振舞いが変化する場合もあります。
PiTestでは依存先の変更による影響を「最も依存性が強い」要素である super class
とouter class
のみに限る、という仮説に基づいて変更の有無を判定しています。
この辺の「妥当に思えるが証明されたわけではない」仮説に基づいたロジックである点もインクリメンタル解析がexperimentalな機能として提供されている一因ではないかと思います。
インクリメンタル解析を利用する場合、以下のようにPiTestの実行情報の入力元(historyInputLocation)と出力先(historyOutputLocation)を指定します。前回実行時との差分を対象にミューテーションテストを実施する場合は「出力先」に出力されている前回結果を今回の「入力元」とすれば良いため、両者に同じパスを設定すれば良いです。「複数のブランチを切り替えながら開発している」ような特殊事情がある場合は「出力先」にある結果が求めている前回結果とは異なる可能性があるため、都度設定を切り替えるなどの工夫が必要かもしれません。
より簡単な設定として「一時ディレクトリ」をhistoryの入力元/出力先とする設定(withHistory)も設定が可能です。ただしGradleプラグインの場合はビルドディレクトリが一時ディレクトリとして設定されているようなので、この設定の場合clean
タスクの実行時にhistoryも初期化されることに注意が必要です。
pitest {
・・・
// ミューテーションテストの履歴データの入力元/出力先を設定
historyInputLocation = ".mutation/history"
historyOutputLocation = ".mutation/history"
// よりシンプルに一時ディレクトリを履歴保存先とする設定も可能。この設定時にはInputLocationなどの設定は無視される
// withHistory = true
}
例えば前回記事でのミューテーションカバレッジ改善前の状態を履歴として保持している状況で、テストコードを改善してpitest
タスクを実行すると、履歴から変更があったテストコード(GreetServiceTest)を検出してGreetService
のみを対象としたミューテーションテストが実施されます。
実行ログを見るとインクリメンタル解析によってミューテーションが削減(Incremental analysis reduced number of mutations by 3)されています。準備段階である依存関係の解析処理時間は伸びているものの、テストの実行時間も半分程度まで削減され、合計の実行時間は短縮できました。
解析対象クラスの指定
#インクリメンタル解析を利用すれば対象を絞ったミューテーションテストを簡便に実現できます。しかし、「前回実行時」との差分に対する動作にあたるため、例えば「マージ前にブランチ間で差分があるコードを対象にミューテーションテストを実施したい」ようなケースでは運用に工夫が必要になります。
「ブランチの分岐前のミューテーションテスト結果を保持しておいてマージ前に利用する」ような手段も採れるのですが、「マージ前」のようなケースではGitなどのソース管理ツールが管理している差分情報も活用できます。
PiTestでは以下のようにして変異対象とするクラスやテストクラスを設定可能です。テストクラスを指定するtargetTests
が未指定の場合にはテストクラスの設定としてtargetClasses
と同じ値が使用されますので、改変対象を具体的なクラス名で指定する場合には注意が必要です。
pitest {
・・・
// 変異対象クラス、実行対象テストクラスを配列で指定。ワイルドカードも使用可能
targetClasses = [ "com.example.iwaki.service.GreetService","com.example.iwaki.BackApplication" ]
// 実行対象テストクラスを指定しないとtargetClassesと同じ値が設定されるため、明示的にテストクラスを指定することが推奨される
targetTests = [ "com.example.iwaki.service.*","com.example.iwaki.*" ]
}
この設定にSCMの管理情報から抽出した変更の情報などを反映することで対象クラスを指定可能です。ビルドの度にbuild.gradleを変更するのは煩雑ですので、実行時オプションや環境変数などで変更できるようにしておくと便利でしょう。例えばGradleのプロパティを介して(デフォルトの)対象を指定しておき、実行時オプション(-P)や環境変数(GRADLE_PROJEXT_XXX)で設定を切り替える[5]ようにできます。
- gradle.properties
// デフォルト設定をgradleのプロジェクトプロパティなどで定義することで実行時オプションなどで外部から変更可能に
PITEST_TARGET_CLASSES="com.example.iwaki.*"
PITEST_TEST_CLASSES="com.example.iwaki.*"
- build.gradle
----
pitest {
・・・
// gradle.propertiesの設定値を配列に変換して設定
targetClasses = [ PITEST_TARGET_CLASSES ]
targetTests = [ PITEST_TEST_CLASSES ]
}
変更されたクラスを対象として指定すればインクリメンタル解析と同様にテストの実行時間が短縮できます。同一ブランチで開発を進めている時にはインクリメンタル解析/マージ前に変更内容を確認するときはSCMの情報を取り込み のように用途に応じて使い分けると便利です。
PiTestのMavenプラグインではMavenのSCM Pluginと連携して変更されたファイルを対象にミューテーションテストを実施するscmMutationCoverageというgoalが提供されています。
Gradleプラグインでこのような機能の提供がない一因は、ビルドに必要な細かい処理は(独自pluginなど)タスクを各自で実装可能である というツールの特色があるのかもしれません。
なお、Gradleでもscmと連携する3rd Party製のプラグインはいくつか見られますが、筆者が確認した範囲では開発が停止していたり限定的な用途のものでした。
GradleからSCMの情報を利用してPiTestを実行する場合にはGradleの外部でSCMを操作する(例えばCIジョブ中であればGradleタスク実行前にSCMツールから情報を取得してGradleに情報を引き渡すなど)形を検討するのが安全そうです。
実行テストコードの絞り込み
#前回結果から変更があったコードに限定してミューテーションテストを実施することで処理を最適化できる可能性については別途記載しました。それ以外に、例えば結合テストやe2eテストコードのように実行時間が長いテストコードなどをミューテーションテストの対象から除外しても所要時間の短縮が狙えます。
「よくあるサンプルアプリ」ではPactを用いたContract Testの所要時間が長いものとなっています。このテストはConsumer側たるフロントエンドからの呼出しに対する応答の組み合わせ(Contract)を検証するものであり、サービス間の結合可能性を評価するためのテストです。他サービスとの結合を(実際に結合する前に)評価するテストですので、変異を入れて十分性を評価する意味が薄い[6]ものでした。
ミューテーションテストの対象を指定するのと同様に、以下のように除外するクラスやテストクラスを指定できます。
pitest {
・・・
// 変異対象外とするクラス、テストクラスを配列で指定。ワイルドカードも使用可能
excludedClasses = [ "com.example.iwaki.BackApplication","com.example.iwaki.ClockConfig" ]
excludedTestClasses = [ "com.example.iwaki.controller.GreetContractTest" ]
}
テストの処理時間が長いContract Testや、Configなどミューテーションテスト対象とする意味が薄いクラスを実行対象から除外することにより実行時間の改善が確認できました。
本稿ではクラス単位で対象/除外を設定する例を紹介しましたが、メソッド単位(excludedMethods/includedTestMethods)やテスティングフレームワークのグループ単位(includedGroups/excludedGroup)での設定も可能です。
変異パターンの絞り込み
#テスト対象に対する変異の埋め込みは、執筆時点では公式ドキュメントを参照すると初期状態で下表に示す11種が有効化されています。また、変異を加えるロジックであるミューテーターは下表以外の18種もあわせて提供されており、個別のミューテーター名またはツールが提供するグループ名を指定して利用するミューテーターの変更が可能です。
ミューテーター | 概要 | 検出されるテスト漏れの例(※) |
---|---|---|
CONDITIONALS_BOUNDARY | 比較演算子の境界をずらす(> → >= など) |
境界値のテスト漏れ |
INCREMENTS | イン(デ)クリメントの反転(++ → -- など) |
ループ処理に対する「複数入力」のテスト漏れ |
INVERT_NEGS | 数値変数の反転(i → -i など) |
値の検証不足(0 など正負に違いがあっても影響ないケースの場合を含む) |
MATH | 数学演算子の変更(a + b → a - b など) |
値の検証不足(0 + 0 など演算子に間違いがあっても影響ないケースの場合を含む) |
NEGATE_CONDITIONALS | 比較演算子の反転( == → != など) |
同値クラスのテスト漏れ(x == a はテストしたがx != a は未テストなど) |
VOID_METHOD_CALLS | 返り値がない(void)メソッド呼出しの削除 | 該当メソッドの影響(事後条件など)のテスト漏れ |
EMPTY_RETURNS | メソッドの戻り値を(その型にあった)空値に変更(string型なら""を返す など) |
後続処理での空値のテスト漏れ |
FALSE_RETURNS | booleanを返すメソッドの戻り値をfalseに変更 | 該当メソッドの結果によるパターンのテスト漏れ |
TRUE_RETURNS | booleanを返すメソッドの戻り値をtrueに変更 | 該当メソッドの結果によるパターンのテスト漏れ |
NULL_RETURNS | (NotNull制約がない)メソッドの戻り値をNullに変更 | 後続演算でのNullパターンのテスト漏れ |
PRIMITIVE_RETURNS | プリミティブ数値(int,floatなど)の戻り値を0に変更 | 戻り値の後続演算で0割りとなるパターンのテスト漏れ |
※:変異内容に基づき筆者が推定
テスト対象の変異はミューテーターの作用によるため、利用するミューテーターの数と加えられる変異の数にはある程度の相関が想定されます。採用する開発技法やフレームワークなどにより発生し難いテスト漏れのパターン[7]などに対しては、ミューテーターを絞り込むことで実行時間が短縮できるかもしれません。
今回はデフォルト設定されている各ミューテーターを外しても良さそうな根拠が希薄ですので、逆にミューテーターの数を増やしたら所要時間が伸びることを確認するのにとどめます。
pitest {
・・・
// 利用するミューテーター名、またはツール側でまとめられたグループ名を指定
mutators = [ "ALL" ]
}
まとめ
#以前に紹介したミューテーションテストツールPiTestについて、利用の障害となりうる「実行時間」を短縮するための設定について紹介しました。通常のテストに比べて時間がかかること自体は避けられないのですが、解析対象を適切に設定すれば現実的に利用可能なものになってくるのではないでしょうか。
予定を見ていた方はお気づきかもしれませんが、本稿タイトルはアドベントカレンダー3日目に投稿を予定していたタイトルのものになります。前回記事に今回の内容まで含めようとしていたのですが、分量的に大きくなりそうだったので分割してみることにしました。。。ということにしておいてください。 ↩︎
今回のサンプルアプリはPact brokerを利用したContract Testなど所用時間が長めのテストを含んでいます。とは言え、前回記事時点ではテスト対象クラスが4クラス、テストクラス数が2クラスという状況で数十秒の所要時間でした。テスト対象が数百クラスの単位になったら分~時間単位の実行時間になっていくことは想像に難くないですよね。テスト実行が1時間だとしたら、リリース前に1回なら許容できたとしても、日々の開発の中で使うのはちょっと躊躇われます。 ↩︎
実際には処理が並列で動くこともありそこまで単調な掛け合わせで増加していくわけではないです。が、「テスト対象に改変を加えたものに対して(もとの)テストを実行し、改変がテスト失敗として検出できたか評価する」アプローチですので傾向としては正しいものと思います。 ↩︎
理論的にはミューテーターが実行のたびに異なった改変を生むような場合など、ソースコードに変化がなくても結果が変わるパターンも考えられます。が、技術のコンセプトからすればそのようなケースはツール側の問題として評価すべきですよね。 ↩︎
プラグインの公式ドキュメントでは設定の上書きにgradle-override-pluginを利用する手法が紹介されています。しかし、このプラグインは配列値の上書きなどに制限があるようでしたので、プロパティの(カンマ区切りで指定した)文字列を入力としてbuild.gradleの中で配列に変換する形式をとりました。 ↩︎
ここでのテスト目的においてはそのとおりなのですが、例えばControllerクラスの単体テストレベルの内容を結合テストでカバーするようなアプローチを採っている場合など、単純に結合テストレベルのテストを除外するとテスト全体として「未カバー」が多くなってしまうこともあります。ミューテーションテストの対象コードを絞り込む際にはテスト全体でのアプローチも考慮のうえ判断できると良いです。 ↩︎
例えばテスト駆動開発を採用している場合には「テスト対象の振舞いをテストコードで定義してからそれを充足するように対象を修正する」開発スタイルであるため、実装されたコードにおいて「ある条件=同値クラス」のテストパターン漏れは発生し難いです。この場合はNEGATE_CONDITIONALSミューテーターの利用価値は相対的に下がるかもしれません。 ↩︎