PHP で mutation testing を試す
Mutation testing とは、プログラムに対するテストケースが十分であるかを測定する手法です。テスト対象のプログラムを機械的に変更してバグを埋め込み、テストがちゃんと失敗するかどうかを調べます。バグのあるプログラムをテストするので、テストの失敗はバグを検出できたことを意味します。さまざまなバグを作り出してテストを行い、バグの検出率によってテストケースの十分さを測定します。
PHP で mutation testing を行うフレームワークとして、Infection PHP があります。今回はこれを利用して、PHP のプログラムに対して mutation testing を試してみます。
infection.github.io
プログラムの準備
まず準備として、テスト対象のプログラムを実装し、PHPUnit で単体テストを実行できるところまで進めます。composer を利用して環境を作成します。
$ mkdir src tests $ composer init -n $ composer require --dev phpunit/phpunit
ここまでの手順を実行した後に autoload の設定を追加した状態で、composer.json は次のようになっています。名前空間は MutationTesting としました。
{ "require": {}, "require-dev": { "phpunit/phpunit": "^7.4" }, "autoload": { "psr-4": { "MutationTesting\\": "src/" } } }
テスト対象のプログラムとして src/BinarySearch.php を作成しました。ソート済みの配列に対する二分探索です。配列 $arr に $x が含まれていればそのインデックスを返します。$x が含まれていなければ -1 を返します。
<?php namespace MutationTesting; class BinarySearch { public function search($x, $arr) { $low = 0; $high = count($arr); while ($low < $high) { $mid = (int) (($low + $high) / 2); $val = $arr[$mid]; if ($val == $x) { return $mid; } else if ($val < $x) { $low = $mid + 1; } else { $high = $mid; } } return -1; } }
このプログラムに対するテストケースを作成します。tests/BinarySearchTest.php に 3 個のテストケースを書いてみました。
<?php namespace MutationTesting; class BinarySearchTest extends \PHPUnit\Framework\TestCase { // 空の配列 public function testEmptyArray() { $object = new BinarySearch(); $actual = $object->search(1, []); $this->assertEquals(-1, $actual); } // 配列中に値が存在する場合 public function testFound() { $object = new BinarySearch(); $actual = $object->search(7, [1, 3, 5, 7, 9]); $this->assertEquals(3, $actual); } // 配列中に値が存在しない場合 public function testNotFound() { $object = new BinarySearch(); $actual = $object->search(6, [1, 3, 5, 7, 9]); $this->assertEquals(-1, $actual); } }
ここでテストを実行して、ちゃんと pass することを確認します。
$ vendor/bin/phpunit tests PHPUnit 7.4.4 by Sebastian Bergmann and contributors. ... 3 / 3 (100%) Time: 43 ms, Memory: 4.00MB OK (3 tests, 3 assertions)
Infection PHP の導入と設定
それでは Infection PHP をインストールして mutation testing を試してみます。Infection PHP は composer でインストールできます。
$ composer require --dev infection/infection
ここで phpunit.xml を作成しておきます*1。次のように適当に作成しました。
<?xml version="1.0"?> <phpunit> <testsuite name="tests"> <directory>./tests</directory> </testsuite> </phpunit>
Infection PHP の設定ファイルを作成します。設定ファイルを作っていない状態で vendor/bin/infection を実行すると、対話的に作成できるようです。
$ vendor/bin/infection You are running Infection with xdebug enabled. ____ ____ __ _ / _/___ / __/__ _____/ /_(_)___ ____ / // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \ _/ // / / / __/ __/ /__/ /_/ / /_/ / / / / /___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/ Welcome to the Infection config generator We did not find a configuration file. The following questions will help us to generate it for you.
最初に、ソースコードが存在するディレクトリを選択します。既定値が src になっていたのでそのまま進めます。
Which source directories do you want to include (comma separated)? [src]: [0] . [1] src [2] tests [3] vendor >
選択したディレクトリの中で、対象外にするディレクトリがあれば設定します。Bundle のテストケースなどを除外しておくようです。Mutation testing ではプログラムを変換してバグを埋め込みますが、単体テストを変換してテストケース側にバグを埋め込むのは無意味なので、そういったものはここで除外します。今回は除外するものはないので、特に指定しません。
Any directories to exclude from within your source directories?: There can be situations when you want to exclude some folders from generating mutants. You can use glob pattern (*Bundle/**/*/Tests) for them or just regular dir path. It should be relative to the source directory. You should not mutate test suite files. Press <return> to stop/skip adding dirs.
テストのタイムアウトを設定します。Mutation testing では、バグの内容によっては停止しないプログラムになってしまうこともあります。そのため、所定の時間でタイムアウトする機能があります。既定値は 10 秒ですが今回は 3 秒に変更しました。
Single test suite timeout in seconds [10]: 3
ログファイルの出力先を指定します。今回は特に変更しません。
Where do you want to store the text log file? [infection.log]:
これで設定が完了し、infection.json.dist ファイルが作られます。なお、私の環境では以下のように続けてテストが動き出し、なぜかそのまま停止しませんでした。Ctrl+C で強制終了しました。
[2019-09-07 追記] ここでテストが停止しない問題は既に解消されています。
Configuration file "infection.json.dist" was created. 0 [>---------------------------] < 1 secRunning initial test suite... PHPUnit version: 7.4.4 4 [---->-----------------------] 6 secs ^C
Infection PHP の実行
設定ファイルが作成されたので、vendor/bin/infection コマンドで mutation testing を実行できます。以下は実行の様子です。
$ vendor/bin/infection You are running Infection with xdebug enabled. ____ ____ __ _ / _/___ / __/__ _____/ /_(_)___ ____ / // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \ _/ // / / / __/ __/ /__/ /_/ / /_/ / / / / /___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/ 0 [>---------------------------] < 1 secRunning initial test suite... PHPUnit version: 7.4.4 6 [============================] < 1 secProcessing source code files: 0/1 Generate mutants... Processing source code files: 1/1Creating mutated files and processes: 0/20 Creating mutated files and processes: 20/20 .: killed, M: escaped, S: uncovered, E: fatal error, T: timed out .M....T....M..TT.... (20 / 20) 20 mutations were generated: 15 mutants were killed 0 mutants were not covered by tests 2 covered mutants were not detected 0 errors were encountered 3 time outs were encountered Metrics: Mutation Score Indicator (MSI): 90% Mutation Code Coverage: 100% Covered Code MSI: 90% Please note that some mutants will inevitably be harmless (i.e. false positives). Time: 11s. Memory: 10.00MB
この実行では、元プログラム (src/BinarySearch.php) を変更して 20 種類の「バグのあるプログラム」が生成され、そのうち 15 個はテストケースで検出できた、2 個は検出できなかった、3 個はテストがタイムアウトした、という情報が出力されています。20 種類のうち検出できなかったものが 2 個あるので、用意した単体テストでのバグ検出率は 90% と報告されています*2。
検出できなかったバグやタイムアウトしたバグの内容は infection.log に出力されます。実際に確認してみると、検出できなかった 2 個のバグは以下のようなものでした*3。
1) /.../src/BinarySearch.php:8 [M] OneZeroInteger --- Original +++ New @@ @@ { public function search($x, $arr) { - $low = 0; + $low = 1; $high = count($arr); while ($low < $high) { $mid = (int) (($low + $high) / 2); 2) /.../src/BinarySearch.php:15 [M] LessThan --- Original +++ New @@ @@ if ($val == $x) { return $mid; } else { - if ($val < $x) { + if ($val <= $x) { $low = $mid + 1; } else { $high = $mid;
前者は初期化の誤りで、これだと配列の先頭要素を無視していることになります。一方、後者は演算子に等号を含めるように変更していますが、等号が成立するケースは手前の $val == $x で処理されているので、この変更はプログラムの動作には影響を与えません。これは、機械的なプログラム変換なのでどうしても発生してしまう問題です。実行結果にも次のように表示されているとおりです。
Please note that some mutants will inevitably be harmless (i.e. false positives).
さて、前者のバグを発見できるようなテストケースを追加してみます。配列の先頭要素と一致する場合として、次のテストケースを追加しました。
<?php ... // 配列の先頭要素が探索する値と一致する場合 public function testFirstElement() { $object = new BinarySearch(); $actual = $object->search(1, [1, 3, 5, 7, 9]); $this->assertEquals(0, $actual); } }
改めて実行すると次のような結果となり、テストケースを追加したことによって、さきほどは検出できなかったバグを検出できていることがわかります。
20 mutations were generated: 16 mutants were killed 0 mutants were not covered by tests 1 covered mutants were not detected 0 errors were encountered 3 time outs were encountered
Infection PHP が生成するバグのバリエーションを確認する
Mutation testing では、プログラムをどのように変更するかというバリエーションが重要です。Infection PHP でサポートされている操作の一覧は、以下のページにまとめられています。
Mutators — Infection PHP
これらの操作によって生成される「バグのあるプログラム」をすべて列挙する機能は、確認した限りでは残念ながら存在しないようです。ただし、Infection PHP のソースコードを一行書き換えれば簡単に確認できましたので、紹介します。
Process/Listener/MutationTestingConsoleLoggerSubscriber.php を次のように変更します。
$ diff -u MutationTestingConsoleLoggerSubscriber.php{-,} --- MutationTestingConsoleLoggerSubscriber.php- 2018-11-18 11:40:38.000000000 +0900 +++ MutationTestingConsoleLoggerSubscriber.php 2018-11-18 15:53:26.864748952 +0900 @@ -130,7 +130,7 @@ $this->outputFormatter->finish(); if ($this->showMutations) { - $this->showMutations($this->metricsCalculator->getEscapedMutantProcesses(), 'Escaped'); + $this->showMutations($this->metricsCalculator->getAllMutantProcesses(), 'All'); if ($this->output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL) { $this->showMutations($this->metricsCalculator->getNotCoveredMutantProcesses(), 'Not covered');
このように変更して、以下のように -s オプションを付けて実行すると、すべてのバグが列挙されます*4。
$ vendor/bin/infection -s
*1:この後で Infection PHP の設定ファイルを対話的に作成する際、phpunit.xml が見つからないとファイルの場所を尋ねられます。それに答えないと Infection PHP の設定ファイルが作られないようでした。
*2:元プログラムではすべてのテストが成功することを前提とするので、タイムアウトしたケースもバグを検出できたとして扱うのだと思います。
*3:ここで示すほかに、タイムアウトした 3 個のバグも出力されていますが、今回の記事では割愛します。
*4:もともと -s は mutants を表示するオプションなのですが、上記のコードが示すように、テストで検出できなかった mutants しか表示されないようです。