テストをテストする方法-ミューテーションテスト- #gadvent
はじめに
これはG* Advent Calendarの12日目の記事です。今日はミューテーションテストについて書きます。明日はid:nobusue さんです。
概要
PITというツールの紹介です。「Javaプロダクトコードを機械的に変更してからテストを実行したときに、テストはそれを検知できるのか?」ということを調べてくれるツールで、SpockのテストやGradleからの実行に対応しています。
ミューテーションテスト
ミューテーションテストとはざっくりと言えば「プロダクトコードを変更したなら、その振る舞いも変わるはず。テストはその変更された振る舞いを網羅できているかを調べる」というテストです。
対象規模が小さければ手動で毎回やってもいいわけですけど、ツール化されていると楽なことこの上ないです。ということで、今回はJavaプロダクトコードをミューテートするライブラリであるPITについて紹介します。 Groovyアドベントなのは、「GradleとSpockに対応しているから」という理由くらいです。(無理矢理だ!
コードカバレッジよりミューテーションテストを信じるべき理由
上記の説明だけではコードカバレッジでも一緒じゃないの?結局どの程度テストされているかなんでしょ?と思われるかもしれませんが、コードベースに対するカバレッジを使うのであれば、ミューテーションテストのほうがよいでしょう。理由は「C1カバレッジでは境界値はテストできない。同値クラス内のいずれかがテストされていれば十分だからだ。ミューテーションテストは限定的だが境界値をテストできる。例えば>を>=に変更してテストが失敗するかを見ているからだ。」というところです。
PIT
PITのこちらにどのようなミューテートをするかが載っています。→ Mutation operators
デフォルトで無効になっているミューテートもあります。
PITを選んだのは「現在も活発に開発が継続されている」「Gradle, Spring-Bootに対応している」とかその辺です。 他にもすばらしい点は多々あるのですが、どうだ!すばらしいだろ!って説明出来るだけのスキルがありません。さーせん。
サンプルリポジトリ
kyon-mm / pit-example — Bitbucket
使い方
build.gradleに次のように書き足します。あとはgradle pitestと実行したら、ミューテーションテストが実行されて、HTMLのレポートが出力されます。
plugins { id "info.solidsoft.pitest" version "1.1.1" } // 他の設定をする(ここでは割愛) pitest { targetClasses = ['org.kyonmm.*'] // ミューテーション対象パッケージの指定 pitestVersion = "1.1.2" // pitestのバージョン指定 threads = 4 // 実行スレッド数指定 outputFormats = ['XML', 'HTML'] // レポートファイルの指定 }
例はFizzBuzzをテストしています。コンストラクタ以外はミューテーションのカバレッジが100%になっています。
コンソール
☁ pit-example gradle pitest Parallel execution is an incubating feature. :compileJava UP-TO-DATE :compileGroovy UP-TO-DATE :processResources UP-TO-DATE :classes UP-TO-DATE :compileTestJava UP-TO-DATE :compileTestGroovy :processTestResources UP-TO-DATE :testClasses :pitest 7:54:58 PIT >> INFO : Verbose logging is disabled. If you encounter an problem please enable it before reporting an issue. 7:54:58 PIT >> INFO : SLAVE : objc[45721]: Class JavaLaunchHelper is implemented in both /Library/Java/JavaVirtualMachines/jdk1.8.0_05.jdk/Contents/Home/jre/bin/java and /Library/Java/JavaVirtualMachines/jdk1.8.0_05.jdk/Contents/Home/jre/lib/libinstrument.dylib. One of the two will be 7:54:58 PIT >> INFO : SLAVE : used. Which one is undefined. 7:54:58 PIT >> INFO : Sending 2 test classes to slave 7:54:58 PIT >> INFO : Sent tests to slave 7:54:59 PIT >> INFO : SLAVE : 7:54:59 PIT >> INFO : Found 2 tests 7:54:59 PIT >> INFO : SLAVE : 7:54:59 PIT >> INFO : Dependency analysis reduced number of potential tests by 0 7:54:59 PIT >> INFO : SLAVE : 7:54:59 PIT >> INFO : 2 tests received /- 7:55:00 PIT >> INFO : Calculated coverage in 1 seconds. 7:55:00 PIT >> INFO : Created 1 mutation test units stderr : objc[45722]: Class JavaLaunchHelper is implemented in both /Library/Java/JavaVirtualMachines/jdk1.8.0_05.jdk/Contents/Home/jre/bin/java and /Library/Java/JavaVirtualMachines/jdk1.8.0_05.jdk/Contents/Home/jre/lib/libinstrument.dylib. One of the two will be stderr : used. Which one is undefined. / 7:55:01 PIT >> INFO : Completed in 3 seconds ================================================================================ - Timings ================================================================================ > scan classpath : < 1 second > coverage and dependency analysis : 1 seconds > build mutation tests : < 1 second > run mutation analysis : 1 seconds -------------------------------------------------------------------------------- > Total : 3 seconds -------------------------------------------------------------------------------- ================================================================================ - Statistics ================================================================================ >> Generated 14 mutations Killed 14 (100%) >> Ran 14 tests (1 tests per mutation) ================================================================================ - Mutators ================================================================================ > org.pitest.mutationtest.engine.gregor.mutators.ConditionalsBoundaryMutator >> Generated 2 Killed 2 (100%) > KILLED 2 SURVIVED 0 TIMED_OUT 0 NON_VIABLE 0 > MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0 > NO_COVERAGE 0 -------------------------------------------------------------------------------- > org.pitest.mutationtest.engine.gregor.mutators.ReturnValsMutator >> Generated 4 Killed 4 (100%) > KILLED 4 SURVIVED 0 TIMED_OUT 0 NON_VIABLE 0 > MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0 > NO_COVERAGE 0 -------------------------------------------------------------------------------- > org.pitest.mutationtest.engine.gregor.mutators.MathMutator >> Generated 3 Killed 3 (100%) > KILLED 3 SURVIVED 0 TIMED_OUT 0 NON_VIABLE 0 > MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0 > NO_COVERAGE 0 -------------------------------------------------------------------------------- > org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator >> Generated 5 Killed 5 (100%) > KILLED 5 SURVIVED 0 TIMED_OUT 0 NON_VIABLE 0 > MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0 > NO_COVERAGE 0 -------------------------------------------------------------------------------- BUILD SUCCESSFUL Total time: 8.111 secs
レポートのHTML
例えば、1<=number<=100の境界値がテストされていない場合。つまり、例外が投げられることを確認するテストが-10と200だけを使っているし、他のテストにおいても2から98くらいの間でしかテストを書いていない場合はこんなレポートになります。カバレッジが減っていますね。
使いどころ
もちろん、これで完璧に境界値をテストしているとは言えないわけですが、単純にコードカバレッジでC1とかよりは信頼のおける数字で、僕が思う使いどころは基本的に2つあります。
- 現在のテストがプロダクトコードの仕様化にどの程度貢献しているかを知る
- テストコードを変更するときの確認
C1カバレッジがどうこうって言ったときに言えるのは「それっぽいテストを書いた」というくらいなので、少なくとももう少し機械的に「境界値分析したのかなぁ?」ということを全体をレビューしながらやるときの当たりをつけるのにも便利です。
テストコードを変更するときに、まずはミューテーションカバレッジを100%にしてから、それが保たれている事を確認しながら行うとテストコードへのバグ混入が少なくなります。
まとめ
いろんな言語でのミューテーションテストツールがもっと増えたら(僕が)うれしいです。各言語のASTいじるのが好きな人はこういったツールを実装するのも楽しいと思いますよ!