Laravel(Pest)でInfectionを利用したMutation Testingを試してみる
TL;DR
- Mutation Test とは
- 実際に
Infection
という PHP のライブラリを利用して、Mutation Test をやってみる
今回サンプルとして、こちらのプロジェクトを利用します。
また、この記事はPHPカンファレンス関西2024で発表した内容でもあります。
気になる方はこちらのスライドも併せてご覧ください。
検証環境について
- PHP: 8 系
- Laravel: 10 系
- Pest: 2.5 系
- Infection: 0.27.6
Mutation Test とは
まず Mutation とは、** 突然変異 = コードを意図的に変更し、バグを植え付けること**だそうです。
Mutation Test においてはライブラリなどを利用し、コードの一部を意図的に変更します。
例えば、a===0
をa!==0
のように改変する(=変異させる)といった感じです。
その後テストを実行し、正しくテストが書かれていたとすれば、結果は変異前と変わるためテストが失敗するはずです。
しかし、正しくテストが書かれていなければテストは失敗しません。
Mutation Test においてはこれを、Killed
とSurvived
と定義しています。
- Killed
- 変異後、成功すべきテストが失敗したことにより検知された変異の数
- Survived
- 変異後、失敗すべきテストが成功したことにより検知された変異の数
つまり、Survived
の数が多ければ多いほど、テストコードの品質が低いことを著しています。
以上から、Mutation Test を導入することで何がよいかというと、見かけ上のコードカバレッジが高く、作成したソースコード全般的にテストコードが網羅できていたとしても、テストコードが正しく書けているとは限らないので、その部分を担保してくれるということです。
例えば以下のようなテストコードはカバレッジ 100%ですがテストには全く意味がありません。()
<?php
it('returns a successful response', function () {
$response = $this->get('/');
});
Mutation Testing 前にテストカバレッジを確認する
まず、Pest のテストカバレッジを出力するにあたってphp.ini
を修正します。
;略
[xdebug]
- xdebug.mode=debug
+ xdebug.mode=debug, coverage
xdebug.start_with_request=yes
xdebug.client_host=host.docker.internal
xdebug.client_port=9003
xdebug.log_level = 0
xdebug.idekey="VSCODE"
xdebug.log=/tmp/xdebug.log
その後、以下のコマンドを実行することでreport/coverage
にカバレッジレポートが出力されるようになります。
php artisan test --coverage-html report/coverage
カバレッジレポートを確認すると、確かに行レベルでは 89.38%とそこそこ高いカバレッジになっているようでした。
ではこの数値が本当に信頼できるものなのかを、Mutation Test によって検証してみます。
Infection の導入と検証
composer require --dev infection/infection
導入後、vendor/bin/infection
を実行することで設定ファイルの作成と初回実行をしてくれます。
設定ファイル作成時に聞かれることは、
- Mutate を発生させる対象ディレクトリ
- Mutation Testing の対象から除外するディレクトリ
- テキストログファイルをどこに保存するか
この 3 点です。今回は以下のように回答し設定ファイルが作成された後infection
が実行されますが失敗しています。
Which source directories do you want to include (comma separated)? [app,database/factories,database/seeders]:
[0 ] .
[1 ] app
[2 ] bootstrap
[3 ] config
[4 ] coverage-result
[5 ] database
[6 ] node_modules
[7 ] public
[8 ] resources
[9 ] routes
[10] storage
[11] tests
[12] vendor
[14] database/factories
[15] database/seeders
> app
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.
Any directories to exclude from within your source directories? []:
Infection may save execution results in a text log for a future review.
This can be "infection.log" but we recommend leaving it out for performance reasons.
Press <return> to skip additional logging.
Where do you want to store the text log file? []:
Configuration file "infection.json5" was created.
____ ____ __ _
/ _/___ / __/__ _____/ /_(_)___ ____
/ // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \
_/ // / / / __/ __/ /__/ /_/ / /_/ / / / /
/___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/
#StandWithUkraine
Infection - PHP Mutation Testing Framework version 0.27.6
Running initial test suite...
PHPUnit version: 10.1.1
3 [============================] < 1 sec
[ERROR] Project tests must be in a passing state before running Infection.
Infection runs the test suite in a RANDOM order. Make sure your tests do not have hidden dependencies.
You can add these attributes to `phpunit.xml` to check it: <phpunit executionOrder="random"
resolveDependencies="true" ...
If you don't want to let Infection run tests in a random order, set the `executionOrder` to some value, for
example <phpunit executionOrder="default"
Check the executed command to identify the problem: '/framework/vendor/bin/phpunit' '--configuration'
'/tmp/infection/phpunitConfiguration.initial.infection.xml' '--coverage-xml=/tmp/infection/coverage-xml'
'--log-junit=/tmp/infection/junit.xml'
PHPUnit reported an exit code of 1.
Refer to the PHPUnit's output below:
STDOUT:
Pest\Exceptions\InvalidPestCommand
Please run [./vendor/bin/pest] instead.
これはデフォルトのテストランナーがphpunit
になっているからです。
今回は Pest がターゲットになるため、テストランナーの変更が必要です。
またレポートを html 形式で出力してほしいため、そのあたりの設定を反映させます。
{
$schema: "vendor/infection/infection/resources/schema.json",
source: {
directories: ["app"],
},
logs: {
+ html: "report/mutation/infection.html",
},
mutators: {
"@default": true,
},
+ testFramework: "pest",
}
ここでvendor/bin/infection
を再実行します。
____ ____ __ _
/ _/___ / __/__ _____/ /_(_)___ ____
/ // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \
_/ // / / / __/ __/ /__/ /_/ / /_/ / / / /
/___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/
#StandWithUkraine
Infection - PHP Mutation Testing Framework version 0.27.6
Running initial test suite...
Pest version: 2.5.2
52 [============================] 5 secs
Generate mutants...
Processing source code files: 42/42
.: killed, M: escaped, U: uncovered, E: fatal error, X: syntax error, T: timed out, S: skipped, I: ignored
UUUUUUUUUUUUUUUUUUUUUUUUUUUUUU.................... ( 50 / 247)
.................................................. (100 / 247)
.................................................. (150 / 247)
.................................................. (200 / 247)
............................................... (247 / 247)
247 mutations were generated:
217 mutants were killed
0 mutants were configured to be ignored
30 mutants were not covered by tests
0 covered mutants were not detected
0 errors were encountered
0 syntax errors were encountered
0 time outs were encountered
0 mutants required more time than configured
Metrics:
Mutation Score Indicator (MSI): 87%
Mutation Code Coverage: 87%
Covered Code MSI: 100%
Generated Reports:
- /framework/report/mutation/infection.html
Please note that some mutants will inevitably be harmless (i.e. false positives).
Time: 46s. Memory: 0.03GB. Threads: 1
今度は正常に実行が完了しました。
では、report/mutation
に出力されたレポートを確認します。
どうやらなかなか優秀そうです。作成された変異のうち 9 割ほどは kill できているので、結構有効なテストコードを作成できてそうです。
Mutation された箇所を実際に確認してみる
レポートのうち、Laravel や laravel Breeeze により生成されたコード以外で変異が残っているAll files/Services/Programs/ProgramData.php
をみてみます。
どうやら、getWeekday
に作成された変異が生き残ったままのようです。
ここでは可視性をprotected
,private
に変更された場合にテストケースでエラーを検知できていない = getWeekday
を使用しているのであれば、使用した際の値の検証ができていないことにより変異が生き残ってしまっているようです。
なので、以下のようなテストケースを追加します。
<?php
use App\Services\Programs\ProgramData;
test('ProgramData returns valid weekday', function () {
$expect = 1;
//準備
$programData = new ProgramData();
$programData->setWeekday($expect);
//実行
$actual = $programData->getWeekday();
//検証
expect($actual)->toBe($expect);
});
これでprotected
,private
に変更された場合にエラーとなるため、Mutation Testing としてはkilled
になるはずです。
再度infection
を実行して結果を確認します。
期待通り、全ての Mutation がkilled
に変わったことが確認できました。
速度改善を行う
これまでオプションなしで実行してきましたが、体感でもかなり遅いです。
Infection の実行結果を見ると、46 秒かかっていることが見て取れます。
これを少し早くしていきたいと思います。
--theads=n
高速化の王道、並列実行が可能です。
8 列で実行してみて、結果を検証します。オプションは--threads=8
です
今度は 24 秒ということで、そこそこ早くなりました。
Infection により発生する Mutate のパターン
公式ドキュメントにまとめられていますので、こちらをご参照ください。
課題
ここまで試してきましたが、実践で使うにはもう少し色々試したいところです。
具体的には、
- MSI(テストコードが検出できた変異の数/全ての変異の数)の割合が一定以下の場合、CI をエラーにしたい
-
--min-msi
オプションを付けるとできるっぽい
-
- 毎回全てではなく、Git 上の差分があるファイルだけを対象にしたい
-
--git-diff-lines
オプションを付けるとできるっぽい
-
- CI での実行レポートを参照したい
- Cloud HTML Report(Stryker が提供する SaaS)があり、それを使えるっぽい
これらはまた検証次第記事として公開したいです。
おわりに
見かけ上のカバレッジが信用に足りない可能性があり、テストの品質を上げてくれるのは嬉しいなと思いました。
Kotlin にも Mutation テストをかけるライブラリがあるのか、また調べたいです。
メンバー募集中!
サーバーサイド Kotlin コミュニティを作りました!
Kotlin ユーザーはぜひご参加ください!!
また関西在住のソフトウェア開発者を中心に、関西エンジニアコミュニティを一緒に盛り上げてくださる方を募集しています。
よろしければ Conpass からメンバー登録よろしくお願いいたします。
Discussion