ミューテーション・テスト:不完全な テストを自動で探す ·...

12
ORACLE.COM/JAVAMAGAZINE /////////////////// NOVEMBER/DECEMBER 2016 34 //junit 5 / 週何かしらのコードを書いたという方なら、ほとんどの方がそれと ともに使うユニット・テストも書いていることでしょう。そういう方 はたくさんいます。最近では、ユニット・テストのないコードベースはほと んど見かけなくなりました。多くの開発者は、テストにたくさんの時間を費 やしています。しかしその仕事の質はよいものと言えるでしょうか。 筆者は、7年前に金融サービス会社向けの古い大量のコードベース に関わる仕事をしていたときから、この問いに悩まされてきました。そ のコードは扱いづらいものではありましたが、業務の中核に関わる部分 であったため、新たな要件を満たすために常に更新する必要がありまし た。 コードをメンテナンスできる形にするために、チームは多くの時間を 費やしました。それを見た経営者たちは不安になりました。チームで問題 が発生しており、変更が必要なことはわかっていましたが、もしバグが紛 れ込めば、大規模な被害が発生する可能性もあります。経営者たちは、す べて問題なく進むという確証を得たかったのです。 このコードベースには、多くのテストが付属していました。残念なこと に、テストを詳しく調べなくても、テスト対象のコードと同じような質であ ることはわかりました。そのため、チームのメンバーは、コードを変更する 前に、まず既存のテストの改善と新しいテストの作成に多くの労力を費や しました。 コードを変更する前に良質のテストができていたため、筆者は経営 者たちに心配することはないと伝えました。リファクタリング中にバグが 紛れ込んでも、テストで発見できるからです。結果的に、経営者たちの財 産は守られました。 しかし、筆者の見込みが誤っていたらどうでしょう。チームがテスト・ スイートを信頼できなかった場合や、安全ネットが穴だらけになっていた 場合はどうなっていたでしょうか。また、これに関連するもう1つの問題も ありました。 チームのメンバーがコードを変更したため、テストも変更する必要が ありました。わかりやすいテストにするために、テストをリファクタリング したこともありました。また、コードベース間で機能を移動したために、テ ストを更新して別の形にすることが必要な場合もありました。そのため、 書かれた時点でテストが良質だったとしても、変更後のテストには問題が 紛れ込んでいたかもしれません。そうでないことはどのように確認できた のでしょうか。 本番環境用のコードに対しては、問題を検出するテストがありまし た。しかし、テスト用のコードの問題はどのように検出したのでしょうか。 テスト環境用のテストを書く必要があったのでしょうか。その場合、ゆく ゆくはテスト環境用のテストのテストを書かなければならないのでしょう か。さらに、テストをテストするテストのためのテストも書くことになるの でしょうか。たとえこれに終わりがあったとしても、それがよい終わり方だ とは思えません。 幸いなことに、このような問いには解答があります。多くのチームと 同様、筆者のチームでもコード・カバレッジ・ツールを使用してテストの分 岐のカバレッジを測定していました。 コード・カバレッジ・ツールでは、コードベースのどの部分が十分にテ ストされているかがわかります。テストを変更した場合でも、チームは変 更前と同じくらいのカバレッジになっているかを確認すればよいだけでし た。これで問題は解決です。しかし、本当でしょうか。 このような形でコード・カバレッジに依存することには、小さな問題 がありました。ツールは、コードがテストされたかどうかについて実際には 何も教えてくれません。次のセクションでは、この点について説明します。 HENRY COLES ミューテーション・テスト:不完全な テストを自動で探す 不適切、不完全なユニット・テストをpitestで見つける

Transcript of ミューテーション・テスト:不完全な テストを自動で探す ·...

Page 1: ミューテーション・テスト:不完全な テストを自動で探す · テスト環境用のテストを書く必要があったのでしょうか。その場合、ゆく

ORACLE.COM/JAVAMAGAZINE /////////////////// NOVEMBER/DECEMBER 2016

34

//junit 5 /

先週何かしらのコードを書いたという方なら、ほとんどの方がそれとともに使うユニット・テストも書いていることでしょう。そういう方

はたくさんいます。最近では、ユニット・テストのないコードベースはほとんど見かけなくなりました。多くの開発者は、テストにたくさんの時間を費やしています。しかしその仕事の質はよいものと言えるでしょうか。

筆者は、7年前に金融サービス会社向けの古い大量のコードベースに関わる仕事をしていたときから、この問いに悩まされてきました。そのコードは扱いづらいものではありましたが、業務の中核に関わる部分であったため、新たな要件を満たすために常に更新する必要がありました。

コードをメンテナンスできる形にするために、チームは多くの時間を費やしました。それを見た経営者たちは不安になりました。チームで問題が発生しており、変更が必要なことはわかっていましたが、もしバグが紛れ込めば、大規模な被害が発生する可能性もあります。経営者たちは、すべて問題なく進むという確証を得たかったのです。

このコードベースには、多くのテストが付属していました。残念なことに、テストを詳しく調べなくても、テスト対象のコードと同じような質であることはわかりました。そのため、チームのメンバーは、コードを変更する前に、まず既存のテストの改善と新しいテストの作成に多くの労力を費やしました。

コードを変更する前に良質のテストができていたため、筆者は経営者たちに心配することはないと伝えました。リファクタリング中にバグが紛れ込んでも、テストで発見できるからです。結果的に、経営者たちの財産は守られました。

しかし、筆者の見込みが誤っていたらどうでしょう。チームがテスト・スイートを信頼できなかった場合や、安全ネットが穴だらけになっていた

場合はどうなっていたでしょうか。また、これに関連するもう1つの問題もありました。

チームのメンバーがコードを変更したため、テストも変更する必要がありました。わかりやすいテストにするために、テストをリファクタリングしたこともありました。また、コードベース間で機能を移動したために、テストを更新して別の形にすることが必要な場合もありました。そのため、書かれた時点でテストが良質だったとしても、変更後のテストには問題が紛れ込んでいたかもしれません。そうでないことはどのように確認できたのでしょうか。

本番環境用のコードに対しては、問題を検出するテストがありました。しかし、テスト用のコードの問題はどのように検出したのでしょうか。テスト環境用のテストを書く必要があったのでしょうか。その場合、ゆくゆくはテスト環境用のテストのテストを書かなければならないのでしょうか。さらに、テストをテストするテストのためのテストも書くことになるのでしょうか。たとえこれに終わりがあったとしても、それがよい終わり方だとは思えません。

幸いなことに、このような問いには解答があります。多くのチームと同様、筆者のチームでもコード・カバレッジ・ツールを使用してテストの分岐のカバレッジを測定していました。

コード・カバレッジ・ツールでは、コードベースのどの部分が十分にテストされているかがわかります。テストを変更した場合でも、チームは変更前と同じくらいのカバレッジになっているかを確認すればよいだけでした。これで問題は解決です。しかし、本当でしょうか。

このような形でコード・カバレッジに依存することには、小さな問題がありました。ツールは、コードがテストされたかどうかについて実際には何も教えてくれません。次のセクションでは、この点について説明します。

HENRY COLES

ミューテーション・テスト:不完全な テストを自動で探す不適切、不完全なユニット・テストをpitestで見つける

Page 2: ミューテーション・テスト:不完全な テストを自動で探す · テスト環境用のテストを書く必要があったのでしょうか。その場合、ゆく

ORACLE.COM/JAVAMAGAZINE /////////////////// NOVEMBER/DECEMBER 2016

35

//junit 5 /

コード・カバレッジの問題点この問題を、前述のコードの中で見つけた古いテストを使って例示します。次のような作為的なクラスについて考えてみます。

class AClass { private int count;

public void count(int i) { if (i >= 10) { count++; } }

public void reset() { count = 0; }

public int currentCount() { return count; }}

このクラスに、次のようなテストがあったとします。

@Testpublic void testStuff() { AClass c = new AClass(); c.count(11); c.count(9);}

このテストは行と分岐のカバレッジが100%になりますが、アサーションが含まれていないため、実際は何のテストにもなっていません。テストによってコードは実行されますが、有意義なテストは行われないのです。このテストを書いたプログラマーは、アサーションを追加し忘れていたのか、統計上のコード・カバレッジを高めるためだけにテストを書いたのか、そのい

ずれかでしょう。幸いにも、このようなテストは静的な分析ツールで簡単に見つけることができます。

さらに、次のようなテストも見つかりました。

@Testpublic void testStuff() { AClass c = new AClass(); c.count(11); assert(c.currentCount() == 1);}

プログラマーは、JUnitのアサーションではなく、assertキーワードを使っています。コマンドラインで-eaフラグを設定してテストを実行しない限り、このテストが失敗することはありません。このような質の悪いテストも、シンプルな静的分析ルールで見つけることができます。

しかし、困ったことに、筆者のチームで問題になったのは、このようなテストではありませんでした。さらに厄介なのは、次のようなケースです。

@Testpublic void shouldStartWithEmptyCount() { assertEquals(0,testee.currentCount());}

@Testpublic void shouldCountIntegersAboveTen() { testee.count(11); assertEquals(1,testee.currentCount());} @Testpublic void shouldNotCountIntegersBelowTen() { testee.count(9); assertEquals(0,testee.currentCount());

コード・カバレッジ・ツールだけでは、コードがテストされているかどうかを判断することはできません。

Page 3: ミューテーション・テスト:不完全な テストを自動で探す · テスト環境用のテストを書く必要があったのでしょうか。その場合、ゆく

ORACLE.COM/JAVAMAGAZINE /////////////////// NOVEMBER/DECEMBER 2016

36

//junit 5 /

}

これらのテストは、countメソッドのコードに含まれる両方の分岐を実行し、返された値のアサーションを実行します。一見、確実そうなテストです。しかし、問題はこのテストではありませんでした。そこになかったテストが問題だったのです。

つまり、目的の値である10が渡された場合にどうなるかを確認するテストが必要だったのです。

@Testpublic void shouldNotCountIntegersOfExcelty10() { testee.count(10); assertEquals(0,testee.currentCount());}

このテストがないと、次のようなバグが偶発的に紛れ込む可能性があります。

public void count(int i) { if (i > 10) { // イコール(=)を忘れている count++; }}

本番環境にあるこのような小さなバグが発見されて修正されるまで、毎日何万ドルもの損害が発生する可能性もあります。

このような問題は、静的な分析では見つけることはできません。ピア・レビューで見つけることができるかもしれませんが、必ず見つかるとは限りません。テスト駆動開発(TDD)を採用している場合、理論上はテストのないコードが書かれることはありません。しかし、TDDを採用したからといって、魔法のように、人々が間違いを起こさなくなるわけではありません。

以上のような理由から、コード・カバレッジ・ツールだけでは、コードがテストされているかどうかを判断することはできません。このツールは便利ですが、目的が少しばかり違います。コード・カバレッジ・ツールは、コードのどの部分がテストされていないかを教えてくれるものです。安全

ネットがないのはどのコードかを探してそれを改善したい場合であれば、コード・カバレッジ・ツールを使って簡単に見つけることができます。

ミューテーション・テストによるカバレッジの向上筆者がかつてテストを書いた後で実践していたことの1つは、実装したコードの一部のコメントアウトや、先ほどの例のように<=を<に変更するなどの小さな変更を行うことによるダブルチェックでした。その状態でテストを実行しても失敗しない場合、どこかに間違いがあるということです。

ここから思いついたことがあります。このような変更を自動で行ってくれるツールがあったらどうでしょう。つまり、コードにバグを追加してからテストを実行してくれるツールがあったらどうだろうということです。ツールによってバグが作り出されてもテストが失敗しない行があれば、その部分は適切にテストされていないことがわかります。テスト・スイートが良質なものであるかどうかが確実にわかるのです。

多くの名案と同じように、この案を思いついたのも、筆者が最初ではありませんでした。このアイデアは1970年代に考案されたもので、ミューテーション・テストという名前がついていました。さらに、このテーマは40年間にわたって幅広く研究されており、研究コミュニティによって関連する用語も生み出されていました。

筆者が手動で行ったようなさまざまなタイプの変更はミューテーション・オペレータと呼ばれています。各オペレータは小さな特定のタイプの変更です。たとえば、>=を>に変更する、1を0に変更する、メソッド呼出しをコメントアウトするなどです。

コードにミューテーション・オペレータが適用されると、ミュータントが生まれます。ミュータント版のコードに対してテストが実行された際、いずれかのテストが失敗すると、ミュータントは「kill」されたことになります。テストが失敗しなければ、ミュータントは生き残ります。

研究者たちは、考えられるさまざまなタイプのミューテーション・オペレータの精査や効果的なミューテーション・オペレータの調査を行い、このような人工のバグを検出するテスト・スイートがどのくらい現実のバグを検出できるかについて研究しました。いくつかの自動ミューテーション・テスト・ツールも生まれました。その中には、Javaを対象としたものもあります。

しかし、多くの読者の方にとってミューテーション・テストが初耳なのはなぜでしょうか。ミューテーション・テスト・ツールが開発者の必須ツールになっていないのはどうしてでしょうか。ここでは、問題点の1つにつ

Page 4: ミューテーション・テスト:不完全な テストを自動で探す · テスト環境用のテストを書く必要があったのでしょうか。その場合、ゆく

ORACLE.COM/JAVAMAGAZINE /////////////////// NOVEMBER/DECEMBER 2016

37

//junit 5 /

いて説明します。もう1つの問題点は後ほど説明します。

最初の問題点は単純です。ミューテーション・テストは、非常に計算量の多い操作であるということです。事実、あまりに計算量が多すぎるため、2009年までのほとんどの学術研究は、コードが100行未満の非実用的なプロジェクトのみを対象にしていました。なぜそこまで計算量が多くなるかを理解するために、ミューテーション・テスト・ツールが何をしなければならないかについて考えてみます。

例として、日付や時間を扱う小さなライブラリであるJoda-Timeライブラリに対してミューテーション・テストを行うものとします。このライブラリには、約68,000行のコードと約70,000行のテスト・コードがあります。コードのコンパイルには約10秒、ユニット・テストの実行には約16秒がかかります。

ここでは、ミューテーション・テスト・ツールが7行ごとにバグを追加するものとします。すると、約10,000個のバグが追加されることになります。バグを追加するためにクラスを変更するたびに、コードのコンパイルが必要になります。そのコンパイルに1秒が必要だとします。その場合、ミューテーションを行うために10,000秒、つまり2時間半を超えるコンパイル時間が必要になります(この時間は、生成コストと呼ばれます)。さらに、各ミュータントに対してテスト・スイートを実行しなければなりません。この実行に必要な時間は160,000秒となり、44時間を超えます。つまり、Joda-Timeライブラリにミューテーション・テストを行うには、ほぼ2日間が必要になります。

初期のミューテーション・テスト・ツールの多くは、まさにこの想像上のツールのような動作をしていました。現在でも、このような動作をするツールを売りつけようとする人々を時折見かけることがあります。しかし、こうしたツールの使用は、明らかに現実的ではありません。

筆者は、ミューテーション・テストに興味を持つようになってから、利用できるオープンソース・ツールを調査しました。見つけられたツールの中

で、もっとも優れていると思ったのがJumbleです。そのJumbleも、先ほど説明した単純なツールよりは高速でしたが、それでもかなり遅いものでした。また、ツールを使いにくくしている別の問題点もいくつかありました。

そして、もっとよいツールはできないかと思うようになりました。改善に役立つのではないかと思うコードはすでに手元にありました。テストを並列に実行するコードです。このコードでは、レガシー・コードの静的変数に格納されている状態が変更されても、実行中のテスト同士が干渉しないように、別のクラス・ローダーでテストを実行するようになっていました。筆者はこのテストをParallel Isolated Test(PIT)と呼んでいました。

何日も遅くまで実験を繰り返した結果、ようやくミューテーション・テスト・ツールをよりよいものに改善できました。筆者が開発したPITミューテーション・テスト・ツールは、Joda-Timeライブラリの10,000のミューテーションを約3分で解析できました。

pitestの紹介このツールには、基となったコードベースのイニシャルが含まれています。現在は「pitest」として知られており、世界中で使用されています。

たとえば、学術研究や、CERNの大型ハドロン衝突型加速器の制御システムのように、安全性が非常に重要となる興味深いプロジェクトのいくつかでも使用されています。しかし、主な用途は、ほとんどの開発者が毎日書くようなごく普通のコードのテストです。ところで、先ほど紹介したシステムと比べて、どのようにこれほど劇的な高速化を成し遂げているのでしょうか。

その秘訣の1つ目は、Jumbleで使われている仕組みをそのまま使っていることです。pitestは、2時間半をかけてソース・コードのコンパイルを行うのではなく、直接バイトコードを変更しています。これによって、1秒未満の時間で数十万個のミュータントを生成できるようになります。

しかし、さらに重要なのは、各ミュータントに対してすべてのテストを実行するのではなく、ミュータントをkillできる可能性のあるテストのみを実行していることです。そのようなテストを探すために、pitestはカバレッジ・データを使用しています。

pitestは、どのテストがコードのどの行を実行するかを把握するために、まず各テストの行カバレッジ・データを収集します。ミュータントをkillできる可能性があるのは、ミュータントが存在する行を実行するテストのみです。その他のテストを実行するのは、時間の無駄でしかありません。

失敗するテストがある場合、ミューテーション・テストを行うことはできません。その部分を対象とするミュータントがkillされたように誤って見える可能性があるからです。

Page 5: ミューテーション・テスト:不完全な テストを自動で探す · テスト環境用のテストを書く必要があったのでしょうか。その場合、ゆく

ORACLE.COM/JAVAMAGAZINE /////////////////// NOVEMBER/DECEMBER 2016

38

//junit 5 /

次に、pitestは経験則を使ってどの適用対象テストを最初に実行するかを選択します。テストでミュータントをkillできる場合、pitestは通常、そのテストを1回目か2回目で見つけることができます。

最大のスピードアップを図れるのは、どのテストでも実行されることがないミュータントがある場合です。従来型のアプローチでは、ミュータントをkillできないと判定するために、テスト・スイート全体を実行する必要がありました。しかし、カバレッジ・ベースのアプローチでは、ほとんど何の計算もせずに、即座にその判定を行うことができます。

何のテストもされていないコードは、行カバレッジから特定できます。ミュータントが存在する行がテストされていない場合、スイート内のどのテストをもってしても、そのミュータントをkillすることはできません。そのような場合、pitestは他に何もせずに、ミュータントを生き残っているものとしてマークできます。

pitestの使用プロジェクトでpitestを設定するのは簡単です。EclipseとIntelliJ IDEA向けのIDEプラグインも開発されていますが、筆者個人としては、ビルド・スクリプトを使ってコマンドラインからミューテーションを追加することを好んでいます。後ほど紹介しますが、pitestが持つ便利な機能のいくつかは、コマンドラインからのみ使用できるようになっています。

通常、筆者はビルド・ツールとしてMavenを使っていますが、GradleおよびAnt向けのpitestプラグインも存在します。

Mavenでpitestを設定するのは簡単です。筆者は通常、pitestという名前のプロファイルを使ってpitestをテスト・フェーズにバインドしています。その後、次のようにして‒Pでプロファイルを有効にすると、pitestを実行できます。

mvn -Ppitest test

例として、Googleのアサーション・ライブラリであるTruthのフォークをGitHubに作成し、ビルドにpitestを追加してみました。プロジェクト・オブ

ジェクト・モデル(POM)ファイルの関連セクションは、こちらから確認できます。

早速、順を追って説明してゆきます。<threads>2</threads>により、pitestがミューテーション・テスト

を行う際に2つのスレッドを使うように指示しています。通常、ミューテーション・テストはスケーラビリティが高いため、3つ以上のコアがある場合は、スレッド数を増やしてみるとよいでしょう。<timestampedReports>false</timestampedReports>によ

り、固定の場所にレポートを生成することを指定しています。<mutators><value>STRONGER</value></mutators>によ

り、使うミューテーション・オペレータの数をデフォルトから増やすことを指定しています。現在のところ、POMファイルのこのセクションはコメントアウトされていますが、近いうちに有効化したいと考えています。自分のプロジェクトでミューテーション・テストを試してみようという場合も、まずデフォルト値を使ってみることをお勧めします。

pitestのMavenプラグインは、一般的な慣習に従い、プロジェクトではグループIDがパッケージ構造に一致していることを仮定しています。すなわち、com.mycompany.myprojectという名前のパッケージにコードがある場合、グループIDもcom.mycompany.myprojectであることを想定しています。この仮定が満たされていない場合、pitestの実行時に次のようなエラー・メッセージが表示される可能性があります。

No mutations found.This probably means there is an issue with either the supplied classpath or filters.

Google Truthのグループ名はパッケージ構造に一致していないため、次のセクションを追加しています。

<targetClasses> <param>com.google.common.truth*</param></targetClasses>

パッケージ名の後に*があることに注意してください。pitestはバイトコード・レベルで動作します。また、ソース・ファイルの

パスを指定するのではなく、ロードされるクラスの名前に一致するグロブを指定して設定します。初めてpitestを使う際、この部分でつまずく方が多

コードに対してもっとも効率よくミューテーション・テストを実行できるのは、コードを書くときです。

Page 6: ミューテーション・テスト:不完全な テストを自動で探す · テスト環境用のテストを書く必要があったのでしょうか。その場合、ゆく

ORACLE.COM/JAVAMAGAZINE /////////////////// NOVEMBER/DECEMBER 2016

39

//junit 5 /

くいます。初めてpitestを設定する際に発生することが多いもう1つの問題

は、次のメッセージです。All tests did not pass without mutation when calculating line coverage.Mutation testing requires a green suite.

このメッセージは、失敗するテストがある場合に発生することがあります。失敗するテストがある場合、ミューテーション・テストを行うことはできません。その部分を対象とするミュータントがkillされたように誤って見える可能性があるからです。また、通常どおりmvn testでテストを実行し、すべてのテストが成功した際にこのメッセージが表示されることもあります。その場合、いくつかの原因が考えられます。

pitestは、Surefireテスト・ランナー・プラグインの設定をパースし、その設定をpitestが理解できるオプションに変換しようとします(Surefireは、Mavenがユニット・テストを実行するためにデフォルトで使用するプラグインです。多くの場合、何の設定もいりませんが、場合によっては、テストを動作させるために特殊な設定が必要になることがあります。その設定は、pom.xmlファイルで指定する必要があります)。

残念ながら、現在のpitestは、考えられるすべてのタイプのSurefire設定を変換できるわけではありません。システム・プロパティやコマンドライン引数による設定を利用するテストでは、pitestの設定で再度それらを指定する必要があります。

さらに発見しづらい問題は、テストの順番に関する依存性です。pitestは、さまざまなシーケンスの中でテストを繰り返し実行します。しかし、テストの中には、他の何らかのテストを事前に実行した場合に失敗するものがあるかもしれません。

たとえば、FooTestというテストがあるとします。このテストは、あるクラスの静的変数をfalseに設定します。また、BarTestという別のテストでは、この静的変数がtrueに設定されていることを仮定しています。その場合、BarTestがFooTestの前に実行されれば成功しますが、後に実行されれば失敗します。デフォルトでは、Surefireはテストをランダムに実行しますが、その順番は一定です。新しくテストが追加されると順番は変わりますが、その依存性が明らかになるような順番でテストを実行したことはないかもしれません。pitestでテストを実行したときに、その順番から初めて順番の依存性がわかるかもしれません。

テストの順番の依存性は、とても発見しにくいものです。この依存性を避けるための方法として、テストの開始時に、テストが依存する共有状

態を適切な値へと慎重に設定し、終了したテストをクリーンアップするというものがあります。しかし、それよりはるかに優れたアプローチは、変更可能な共有状態がプログラムにそもそも含まれないようにすることです。

また、Google Truthライブラリを使用するための設定には、次のセクションが含まれています。

<excludedClasses> <param> *AutoValue_Expect_ExpectationFailure </param></excludedClasses>

この設定は、名前がAutoValue_Expect_ExpectationFailureで終わるすべてのクラスではミューテーションを行わないようにするものです。これらのクラスは、Google Truthのビルド・スクリプトで自動的に生成されます。そのようなクラスに対してミューテーション・テストを行っても、何の価値もありません。さらに、ソース・コードがないため、生成されたミューテーションを理解するのも難しいでしょう。

pitestでは、別の方法を使ってコードをミューテーション・テストから除外することもできます。詳細は、pitestのWebサイトをご覧ください。

pitestレポートの理解それでは、サンプルを実行して生成される結果を見てみます。まず、 Google Truthライブラリのソース・コードをチェックアウトし、Mavenを使ってpitestを実行します。

mvn -Ppitest test

これには、Mavenがdependencies要素で指定した依存ライブラリのダウンロードを終えてから60秒ほどかかるはずです。実行が完了すると、target/pitReportsディレクトリにHTMLレポートが生成されます。Truthプロジェクトでは、レポートはcore/target/pitReportsに生成されます。

pitestのレポートは、標準的なカバレッジ・ツールが生成するレポートによく似ていますが、いくつかの追加情報が含まれています。各パッケ

Page 7: ミューテーション・テスト:不完全な テストを自動で探す · テスト環境用のテストを書く必要があったのでしょうか。その場合、ゆく

ORACLE.COM/JAVAMAGAZINE /////////////////// NOVEMBER/DECEMBER 2016

40

//junit 5 /

ージが一覧表示されるとともに、全体の行カバレッジとミューテーション・スコアが並んで表示されます。

各ソース・ファイルにドリルダウンすると、図1のようなレポートが表示されます。

行カバレッジは、行末まで伸びている色付きのブロックで示されます。緑色はテストによって実行された行、赤色は実行されなかった行を示しています。

行番号とコードの間には、それぞれの行に対して生成されたミューテーションの数が表示されています。この数にカーソルを当てると、生成されたミューテーションの説明とそのステータスが表示されます。すべてのミュータントがkillされた場合、コードは濃い緑色になります。生き残ったミュータントがある場合、コードは赤色で強調表示されます。

レポートの最下部にも、有用な情報が表示されています。そのファイルでミュータントに対して行われたすべてのテストと、それぞれの実行に

かかった時間の一覧です。その上には、すべてのミューテーションの一覧も表示されています。そこにカーソルを当てると、ミュータントをkillしたテストの名前を確認できます。

Google Truthは、pitestや他のミューテーション・テスト・ツールを使わずに開発されましたが、全体的にチームの開発の質はかなり高いと言えるでしょう。88%というミューテーション・テストのスコアは、そう簡単に達成できるものではありません。それでも、まだ足りない点はあります。

特に興味深いミュータントは、テストの対象となったことを示す緑色になっている行のミュータントです。ミュータントがテストの対象になっていなかった場合、そのミュータントが生き残ったことは当然であり、行カバレッジ以外の情報が何ももたらされないことは明らかです。しかし、ミュータントがテスト対象になっていた場合、そこからわかることがあります。

たとえば、PrimitiveIntArray

図1: pitestで生成されたレポート

Page 8: ミューテーション・テスト:不完全な テストを自動で探す · テスト環境用のテストを書く必要があったのでしょうか。その場合、ゆく

ORACLE.COM/JAVAMAGAZINE /////////////////// NOVEMBER/DECEMBER 2016

41

//junit 5 /

Subject.javaの73行目を見てみます。pitestが生成した、ミュータントの説明には、次のように記載されています。

removed call to com/google/common/truth/ PrimitiveIntArraySubject::failWithRawMessage

[編集部注:スペースの関係上、メッセージを折り返しています。 ] これは、このメソッドを呼び出している行をpitestがコメントアウトしたことを示しています。

名前が示すように、failWithRaw Messageの役割はRuntimeExceptionをスローすることです。アサーション・ライブラリであるGoogle Truthの主な機能の1つは、条件が満たされない場合にAssertionErrorをスローすることです。

このクラスを対象とするテストを見てみます。次のテストは、その機能をテストしているように見えます。

@Testpublic void isNotEqualTo_FailSame() { try { int[] same = array(2, 3); assertThat(same).isNotEqualTo(same); } catch (AssertionError e) { assertThat(e) .hasMessage("<(int[]) [2, 3]>" + "unexpectedly equal to [2, 3]."); }}

ミスを発見できるでしょうか。これはテストの代表的なバグです。このテストではアサーション・メッセージの内容を確認していますが、例外がスローされなかった場合、テストは成功してしまいます。通常、このパターンのテストには、fail()のコールを含めます。Truthチームが想定している例外はAssertionErrorであるため、他のテストで使われているパターンでは、Errorをスローします。

@Test

public void isNotEqualTo_FailSame() { try { int[] same = array(2, 3); assertThat(same).isNotEqualTo(same); throw new Error("Expected to throw"); } catch (AssertionError e) { assertThat(e) .hasMessage("<(int[]) [2, 3]>" + "unexpectedly equal to [2, 3]."); }}

テストにこのthrow文を追加すると、ミュータントはkillされます。 他にはどのようなことがわかるでしょう

か。PrimitiveDoubleArraySubject.javaの121行目にも同じような問題があります。ここでも、pitestはfailWithRawMessageのコールを削除しています。

しかし、テストを確認すると、例外がスローされない場合はErrorをスローしています。どのような結果になるでしょうか。これは、等価ミュータントと呼ばれるものです。次は、このようなミュータントについて少し詳しく見てゆきます。

等価ミュータント等価ミュータントは、導入部で触れた学術研究によって明らかになった、もう1つの問題点です。

場合によっては、コードを変更しても予測に反して動作がまったく変わらず、変更されたコードが元のコードと論理的に等価な場合があります。そのような場合、変更前のコードで失敗せず、ミュータントのコードでのみ失敗するようなテストを書くことは不可能です。残念ながら、生き残ったミュータントが等価ミュータントなのか、それとも効果的なテスト・ケースがなかったのかを自動的に判定することはできません。この状況では、人手でコードを調べる必要があります。その調査には、時間がかかるかもしれません。

ミューテーションが等価であるかを判断するためには、平均で約15分かかるとした研究もあります。 そのため、プロジェクトの最後にミューテーション・テストを行い、

Page 9: ミューテーション・テスト:不完全な テストを自動で探す · テスト環境用のテストを書く必要があったのでしょうか。その場合、ゆく

ORACLE.COM/JAVAMAGAZINE /////////////////// NOVEMBER/DECEMBER 2016

42

//junit 5 /

何百ものミュータントが生き残った場合は、何日もかけてそれらが等価ミュータントであるかどうかを評価することが必要になるかもしれません。

この点は、ミューテーション・テストを実際に導入する前に解決しなければならない大きな問題だと考えられていました。しかし、ミューテーション・テストに関する初期の研究の多くは、明示されていないある前提に基づいていました。それは、ミューテーション・テストがある種の別のQAプロセスのような形で、開発プロセスの最後に適用されるという前提です。最新の開発は、そのようなものではありません。

pitestを使用している人々の経験によれば、等価ミュータントは大きな問題ではありません。実際のところ、場合によっては有用ですらあります。

コードに対してもっとも効率よくミューテーション・テストを実行できるのは、コードを書くときです。その場合、生き残った少数のミュータントを一度に評価する必要があります。しかし、さらに重要なのは、開発者自身がそれに対応するという点です。コードやテストについての記憶が鮮明なため、生き残ったミュータントを評価する時間は、言われている平均15分よりはるかに短くなります。

書いたばかりのコード内のミュータントが生き残った場合、それは次の3つのいずれかを行うべきだという印です。

■■ ミュータントが等価でないなら、ほとんどの場合はテストを追加することになります。

■■ ミュータントが等価なら、多くの場合はコードの一部を削除します。出現しやすい等価ミュータントの1つに、必要ないコードに対してミューテーションを行ったというものがあります。

■■ そのコードが必要な場合は、コードが行っていることとその実装方法を見直してみるとよいという印かもしれません。先ほど詳しく見たPrimitiveDoubleArraySubject.javaの121行目は、この最後のカテゴリに当てはまる例です。このメソッドの全体は、次のようになっています。

public void isNotEqualTo(Object expectedArray , double tolerance) { double[] actual = getSubject(); try { double[] expected = (double[]) expectedArray; if (actual == expected) { // ミューテーションが行われたのは以下の行 failWithRawMessage( "%s unexpectedly equal to %s." , getDisplaySubject() , Doubles.asList(expected)); } if (expected.length != actual.length) { return; //要素数が異なる配列は等しくない } List<Integer> unequalIndices = new ArrayList<>(); for (int i = 0; i < expected.length; i++) { if (!MathUtil.equals( actual[i] , expected[i] , tolerance)) { unequalIndices.add(i); } } if (unequalIndices.isEmpty()) { failWithRawMessage( "%s unexpectedly equal to %s." , getDisplaySubject() , Doubles.asList(expected)); } } catch (ClassCastException ignored) { // 型が異なるため等しくない }}

pitestは、2つの配列を==演算子で比較し、その条件に応じて実行される

ミューテーション・テストを行う必要があるコードは、今書いたばかり、あるいは今変更したばかりのコードのみです。

Page 10: ミューテーション・テスト:不完全な テストを自動で探す · テスト環境用のテストを書く必要があったのでしょうか。その場合、ゆく

ORACLE.COM/JAVAMAGAZINE /////////////////// NOVEMBER/DECEMBER 2016

43

//junit 5 /

メソッド・コールに対してミューテーションを行いました。コードがこの時点で例外をスローしなかった場合、次に進んで配

列の個々の要素についての比較を実行します。配列が等しくない場合は、==がtrueを返したのとまったく同じ例外がスローされます。

つまり、今回のミューテーションは、純粋にパフォーマンス改善のためだけに存在しているコードに対するものです。このコードの目的は、処理コストの高い個々の要素の比較を避けることです。多くの等価ミュータントはこのカテゴリに分類されます。コードは必要ですが、ユニット・テストではテストできない事象に関連するものです。

これに関して最初に出てくる疑問は、同じ配列が与えられた場合と、同じ中身の2つの異なる配列が与えられた場合で、このメソッドの動作が同じであるべきかどうかということです。

筆者は、動作を変えるべきだと考えます。アサーション・ライブラリを使用しており、2つの配列が等しくないことが想定される状況で同じ配列を2回渡した場合、それを教えてくれるメッセージがあれば便利ではないでしょうか。たとえば、失敗のメッセージの後に「(実は同じ配列です)」というようなメッセージを追加する方法などが考えられます。

しかし、筆者の考えは間違っているかもしれません。現在の動作の方がよいとも考えられます。それでは、動作を変えないとすると、等価ミューテーションをなくすために何ができるでしょうか。

筆者は、このisNotEqualToメソッドが好きになれません。このメソッドは、2つのことを行っています。配列の等価性を比較することと、2つの等価な配列が渡された際に例外をスローすることです。

この2つの役割を2つのメソッドに分割してはどうでしょう。たとえば、次のような形が考えられます。

public void isNotEqualTo(Object expectedArray , double tolerance) { double[] actual = getSubject();

try { double[] expected = (double[]) expectedArray; if (areEqual(actual, expected, tolerance)) { failWithRawMessage( "%s unexpectedly equal to %s." , getDisplaySubject()

, Doubles.asList(expected)); } } catch (ClassCastException ignored) { // 型が異なるため等しくない } }

private boolean areEqual(double[] actual , double[] expected , double tolerance) { if (actual == expected) return true; if (expected.length != actual.length) return false; return compareArrayContents(actual , expected , tolerance); }

こうすれば、等価ミュータントはなくなります。つまり、このミュータントは、コードをもっときれいにリファクタリングできるという印なのです。さらに、新しく作成したareEqualメソッドを使って、このクラスの別の場所にある重複したロジックを排除し、コードの量を減らすこともできます。

しかし、コードを変更したとしても、すべての等価ミュータントを排除できるとは限りません。pitestでさらに強力なミューテーション・オペレータのセットを有効にしている設定セクションのコメントを外してテストを再実行すると、新しいareEqualメソッドにミュータントが出現します。removed conditional - replaced equality check with false

pitestは、メソッドを次のように変更しました。

private boolean areEqual(double[] actual , double[] expected , double tolerance) { if (false) return true; // 追加されたミューテーション

Page 11: ミューテーション・テスト:不完全な テストを自動で探す · テスト環境用のテストを書く必要があったのでしょうか。その場合、ゆく

ORACLE.COM/JAVAMAGAZINE /////////////////// NOVEMBER/DECEMBER 2016

44

//junit 5 /

if (expected.length != actual.length) return false; return compareArrayContents(actual , expected , tolerance);}

こうなると、パフォーマンスを最適化したまま等価ミュータントをなくすリファクタリングはできなくなります。

つまり、すべての等価ミュータントが有用とは限りません。しかし、そういったミュータントは研究で言われているよりも少ないでしょう。

pitestは、できる限り等価ミュータントが発生しないように設計されています。デフォルトのオペレータのセットを使えば、等価ミュータントに遭遇することはほとんどありません。どの程度等価ミュータントが出現するかは、書いているコードの種類やスタイルによって異なります。

大規模プロジェクトでの活用今まで取り上げたサンプル・プロジェクトは、いずれも大規模なものではありませんでしたが、ミューテーション・テストは大規模なプロジェクトでも利用できるのでしょうか。もちろんです。

ここまで説明してきたように、コードを開発する際にテストを実行すれば、はるかに効率的にミューテーション・テストを行うことができます。その場合、プロジェクトの規模は関係ありません。Truthのようなプロジェクトでは、毎回プロジェクト全体に対してミューテーションを行うのがもっとも簡単ですが、そうする必要はありません。

ミューテーション・テストを行う必要があるコードは、今書いたばかり、あるいは今変更したばかりのコードのみです。たとえ何百万行もあるようなコードベースであっても、コードの変更によって影響を受けるのはせいぜい数個のクラスでしょう。

バージョン管理システムと組み合わせてこのような使い方をすれば、pitestを簡単に活用できます。現在、この機能はMavenプラグインを使用する場合にのみ利用できます。

標準的なMavenバージョン管理情報をPOMファイルで正しく設定していれば、pitestのscmMutationCoverageゴールを使ってローカルで

変更されたコードのみを分析できます。このゴールは、Google TruthのPOMのpitest-localプロファイルに

バインドされています。

mvn -Ppitest-local test

チェックアウトしたコードに何の変更も行っていない場合、このゴールはテストを実行して停止します。変更している場合は、変更されたファイルのみを分析します。コードを変更して、試してみてください。

このアプローチによって、現在作業しているコードはうまくテストされているかどうかという、まさに必要な情報が得られます。また、継続的インテグレーション(CI)サーバーが最新のコミットのみを分析できるようにpitestを設定することも可能です。

しかし、プロジェクトすべてのテストの質について、全体像を把握したい場合はどうすればよいでしょうか。

何時間も待つことを許容できない限り、やがてはミューテーション・テストを実行できるプロジェクトの規模の上限に達するでしょう。しかし、pitestでは、その上限をさらに高める試験運用版のオプションも提供されています。

Google Truthプロジェクトに戻り、次のようにして実行してみます。

mvn -DwithHistory -Ppitest test

以前に実行したときとほとんど変わりないように見えるはずです。しかし、もう一度同じコマンドを実行すると、わずか数秒で終了します。withHistoryフラグを指定すると、pitestは毎回実行結果について

の情報を蓄積し、次の実行時に、その情報を使って最適化を行います。たとえば、あるクラスとそれを対象にしたテストが変更されていない場合、そのクラスについての分析を再実行する必要はありません。実行結果の履歴を使うと、同じような最適化をいくつも行うことができます。

この機能はまだ開発の初期段階にあるものですが、プロジェクトの開始時から使えば、どれほど規模が大きくなってもコードベース全体を分析できるようになるはずです。

Page 12: ミューテーション・テスト:不完全な テストを自動で探す · テスト環境用のテストを書く必要があったのでしょうか。その場合、ゆく

ORACLE.COM/JAVAMAGAZINE /////////////////// NOVEMBER/DECEMBER 2016

45

//junit 5 /

まとめミューテーション・テストが強力かつ実用的なテクニックであることはおわかりいただけたのではないかと思います。ミューテーション・テストを活用すれば、強力なテスト・スイートを作成でき、よりきれいなコードを書く助けにもなります。

しかし、本記事は1つの警告で締めくくりたいと思います。ミューテーション・テストを行っても、よいテストであることが保証されるわけではありません。保証されるのは、強力なテストであるということです。強力なテストとは、コードの重要な動作が変わった場合に失敗するテストという意味です。しかし、これは全体像の半分にすぎません。実装の詳細が変わっても、動作が同じであれば失敗しないテストであることも同じくらい重要です。 </article>

Henry Coles(@0hjc):イギリス、スコットランドのエディンバラを本拠地とし、ローカルJUGを運営しているソフトウェア・エンジニア。プロフェッショナルとして、主にJavaを使って20年近くソフトウェアを書いている。pitestをはじめとする多くのオープンソース・ツールを作成し、オープンソース関連の書籍『Java for Small Teams』も執筆している。

『ミューテーション解析』(Wikipedia)

learn more

//java proposals of interest /

最近策定されたJava API for JSON Processing(JSON-P)仕様では、JSONデータの解析や生成を行う標準APIが定義されています。この標準については、本誌28号(Java APIによるJSON処理)で詳しい例を交えて解説しました。JSONパーサーにはいくつかの実装がありますが、JSON-Pのメリットは今後予定されているJava EE 8リリースにバンドルされることです。実際、JSON-PはJSON解析における標準と位置付けられています。

JSR 367の目的は、JSONをJavaオブジェクトに変換する方法とその逆方向の変換を行う方法を標準化することにあります。このJSRでは、JSON-Pを活用してその上に変換レイヤーを提供するJSON-Bが提案されています。JSON-Bの最終バージョンもJava EE 8リリースに含まれる予定です。JSON-Pと同様に、JSON-BもJavaオブジェクトとJSONメッセージの相互変換を行う標準のバインディング・レイヤーと位置付けられています。

このJSRでは、既存のJavaクラスをJSONに変換するデフォルトのマッピング・アルゴリズムが提案されています。このデフォルトのマッピングは、Javaアノテーションを使ってカスタマイズできます。JSON-Bは、JAX-RSなどの他のレイヤーでも活用できるものとなる予定です。

現在、このJSRに対して最終決定に向けた調整が行われており、2016年前半に公開された仕様ドキュメント(PDF)がダウンロードできるようになっています。また、スタート・ガイドが掲載された 美しいWebサイトなどのリソースも公開されています。前述の記事でも述べましたが、この新しい標準は今後のJava Magazineで特集する予定です。それまでの間は、ここで紹介したJSRリソースをご活用ください。

JSR 367:JSON-Bによる JSONバインディング

今注目のJAVA SPECIFICATION REQUEST