PHPカンファレンス沖縄2019でLTをしてきました。
LTの内容はミューテーションテストの基礎的な概要を紹介するといったものだったのですが、5分という時間の制約上伝えられなかったことがたくさんあるので、この記事で補足を兼ねて解説しなおしたいと思います。
※この記事はあくまで僕の解釈です。一般的な解釈と異なる記述がある可能性があります。
ミューテーションテストとは
ミューテーションテストとは、我々が実装したアプリケーションコードの一部を書き換え、書き換えたコードに対してテストを再度実行することでテストの品質を測ろうというものです。
簡単に表現すると「アプリケーションコードに変更が加わったのに、既存のテストがパスするのはおかしいんじゃないか?」という視点で品質を評価するということだと思います。要するにテストコードに対するテストですね。
ミューテーションテストが必要になる理由
一般的なテストコードは、我々が実装したアプリケーションコードに対して実行し、アプリケーションの品質を保証する(したい)ものだと思います。
これはとても重要なもので、テストコードを書くことによって
- アプリケーションの品質の維持
- 安全なリファクタリングの実現
- 実装時に見逃していたバグの発見
- etc
など、いくつかのポジティブな効果が期待できます。 *1
一方でそれらの効果を期待するためには「品質の良いテストコードである」ということが求められるかと思います。しかし、テストコードの品質を数値化するということは非常に難しい課題です。
広く知られる指標としてコードカバレッジがありますが、これはテストでどれだけのアプリケーションコードが実行されたかということを示すだけで、テストコードの品質についてフィードバックを得られるわけではありません。*2
そこで役に立つかもしれないのがミューテーションテストと呼ばれるものです。
どうやっているのか
先に述べたように、アプリケーションコードを変異(ミューテート)させ、それに対して再度テストを実行することでテストの品質を評価します。
どのような変異を起こしてくれるのかというと、これは各テスティングフレームワークによって異なると思います。
PHPにはinfectionというテスティングフレームワークがあり、公式ドキュメントで様々なミューテーター(変異の種類)が紹介されています。
ミューテーターはかなり多く用意されているので、少しだけ紹介すると
説明 | 元コード | 変異後 |
---|---|---|
配列に対する操作の変異 | $a = array_filter(['A', 1, 'C'], 'is_int') | $a = ['A', 1, 'C'] |
論理式の変異 | $a < $b | $a <= $b |
論理式の変異 | $a < $b | $a >= $b |
Loopの制御文の変異 | break | continue |
例外処理の変異 | throw new Exeption | new Exception |
例外処理の変異 | try {} catch (Exception $e) {} finally {} | try {} catch (Exception $e) {} |
など、様々な変異を起こしてくれます
例えば、以下のようなコードがあったとします。
<?php class Number { private $value; public function __construct(int $value) { $this->value = $value; } public function isSmallerThan(int $value): bool { return $this->value < $value; } }
isSmallerThan
というメソッドは、与えられた数値よりも $this->value
の値の方が小さければtrue、そうでなければfalseを返すという簡単なメソッドです。
そして、このコードに対するテストコードが以下です。
<?php use PHPUnit\Framework\TestCase; final class NumberTest extends TestCase { public function testIsSmallerThan() { $number = new Number(15); $this->assertTrue($number->isSmallerThan(20)); } }
15のNumberの isSmallerThan
に20の数値を渡しています。
もちろん15は20より小さいのでtrueを返し、テストはパスします。
この状態でinfectionを使いミューテーションテストを実行してみると以下のような実行結果が出力されます。
--- Original +++ New @@ @@ } public function isSmallerThan(int $value) : bool { - return $this->value < $value; + return $this->value <= $value; } }
この例では <
を <=
に変異させても既存のテストが全てパスしてしまうこと、つまり境界値テストが足りていないことがわかり、(ミューテーションテストから見た)テストの品質をあげる具体的なヒントを手に入れることができました。
このようにして、既存のテストに足りていないと思われるテストをミューテーションテストを通して知ることができるようになります。
課題
ここまでの話でミューテーションテストに対してかなり期待を持った方が多いのではないかと思います(多いといいな)
しかし、ミューテーションテストを実際に運用するとなるといくつか課題が浮かびます。
実行時間
ミューテーションテストは、かなり重い処理になります。 アプリケーションコードが多くなればなるほど多くの時間が必要になり、公式サイトにもアプリケーションの規模によっては数時間かかるかもしれないというような文言があります。
If you have thousands of files and too many tests, running Mutation Testing can take hours for your project.
これに対するアイデアとしては、masterブランチやdevelopブランチと差分のあるファイルにのみinfectionを実行するというものです。
$ infection --threads=4 --filter=$(git diff origin/master --diff-filter=AM --name-only | grep src/ | paste -sd "," -) --ignore-msi-with-no-mutations
この方法であればPRが大きくならない限りは現実的な実行時間でCI上でもミューテーションテストを実行することが可能になると思います。
ミューテーションテストの全ての指摘に対応するのはコストが高い
デフォルトの設定でinfectionを実行してみると、ちょっとしたコードでもなかなかの量の指摘が発生すると思います。
中には有益な指摘も当然ありますが、全てに対応するのはコストの割にはメリットが少なさそうだなというのが僕の感想です。
これに対する有効そうな手段はいくつかあると思います。
min-msiを調整する
msiは Mutation Score Indicatorの略で、ミューテーションテストのスコアです。 MSIは0%から100%までの値を取り、数値が大きければ大きいほどミューテーションテスト的に品質の良いテストということになります。
開発チームで許容出来る最低スコアを設定することで、全ての指摘事項に対応しなくてもミューテーションテストをパスすることが出来るようになります。
ですが当然全ての指摘の重要度が等しいわけではないので、有益な指摘を見逃している場合であってもmin-msiを超えているテストであればミューテーションテストをパスすることになるので注意が必要です。
不要なミューテーターを無効にする
infectionではミューテーターを細かく設定することができます。(https://infection.github.io/guide/profiles.html)
必要と思われるミューテーターのみ有効にすることで、ミューテーションテストの対応コストを調整することも可能です。 が、こちらも有益な指摘を見逃す可能性が発生することを念頭に置く必要があると思います。
おわりに
最近ミューテーションテストの存在を知り、個人で開発しているライブラリに対してinfectionを導入してみましたが、まだまだ検証が足りていません。 どこまでテストの品質が上がるのか、そしてどれだけ有効なのか未知数なので、もうちょっとしっかり触ってみたいなーというところです。 実際にミューテーションテストをゴリゴリ使っていらっしゃる方がいれば是非吉田あひるまでご連絡ください!