🐘

Laravel(Pest)でInfectionを利用したMutation Testingを試してみる

2024/03/04に公開

TL;DR

  • Mutation Test とは
  • 実際にInfectionという PHP のライブラリを利用して、Mutation Test をやってみる

今回サンプルとして、こちらのプロジェクトを利用します。

https://github.com/ysknsid25/otaku-tool

また、この記事はPHPカンファレンス関西2024で発表した内容でもあります。

気になる方はこちらのスライドも併せてご覧ください。

https://speakerdeck.com/ysknsid25/phpkanhuarensuguan-xi-2024

検証環境について

  • PHP: 8 系
  • Laravel: 10 系
  • Pest: 2.5 系
  • Infection: 0.27.6

Mutation Test とは

まず Mutation とは、** 突然変異 = コードを意図的に変更し、バグを植え付けること**だそうです。

Mutation Test においてはライブラリなどを利用し、コードの一部を意図的に変更します。

例えば、a===0a!==0のように改変する(=変異させる)といった感じです。

その後テストを実行し、正しくテストが書かれていたとすれば、結果は変異前と変わるためテストが失敗するはずです。

しかし、正しくテストが書かれていなければテストは失敗しません。

Mutation Test においてはこれを、KilledSurvivedと定義しています。

  • Killed
    • 変異後、成功すべきテストが失敗したことにより検知された変異の数
  • Survived
    • 変異後、失敗すべきテストが成功したことにより検知された変異の数

つまり、Survivedの数が多ければ多いほど、テストコードの品質が低いことを著しています。

JavaScriptのMutation Testing ライブラリ・Strykerのレポート

以上から、Mutation Test を導入することで何がよいかというと、見かけ上のコードカバレッジが高く、作成したソースコード全般的にテストコードが網羅できていたとしても、テストコードが正しく書けているとは限らないので、その部分を担保してくれるということです。

例えば以下のようなテストコードはカバレッジ 100%ですがテストには全く意味がありません。()

<?php
it('returns a successful response', function () {
    $response = $this->get('/');
});

Mutation Testing 前にテストカバレッジを確認する

まず、Pest のテストカバレッジを出力するにあたってphp.iniを修正します。

php 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 形式で出力してほしいため、そのあたりの設定を反映させます。

diff infection.json5
{
    $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を使用しているのであれば、使用した際の値の検証ができていないことにより変異が生き残ってしまっているようです。

なので、以下のようなテストケースを追加します。

ProgramDataTest.php
<?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

高速化の王道、並列実行が可能です。

https://infection.github.io/guide/command-line-options.html#threads-or-j

8 列で実行してみて、結果を検証します。オプションは--threads=8です

今度は 24 秒ということで、そこそこ早くなりました。

Infection により発生する Mutate のパターン

公式ドキュメントにまとめられていますので、こちらをご参照ください。

https://infection.github.io/guide/mutators.html

課題

ここまで試してきましたが、実践で使うにはもう少し色々試したいところです。

具体的には、

  • MSI(テストコードが検出できた変異の数/全ての変異の数)の割合が一定以下の場合、CI をエラーにしたい
    • --min-msiオプションを付けるとできるっぽい
  • 毎回全てではなく、Git 上の差分があるファイルだけを対象にしたい
    • --git-diff-linesオプションを付けるとできるっぽい
  • CI での実行レポートを参照したい
    • Cloud HTML Report(Stryker が提供する SaaS)があり、それを使えるっぽい

これらはまた検証次第記事として公開したいです。

おわりに

見かけ上のカバレッジが信用に足りない可能性があり、テストの品質を上げてくれるのは嬉しいなと思いました。

Kotlin にも Mutation テストをかけるライブラリがあるのか、また調べたいです。

メンバー募集中!

サーバーサイド Kotlin コミュニティを作りました!

Kotlin ユーザーはぜひご参加ください!!

https://serverside-kt.connpass.com/

また関西在住のソフトウェア開発者を中心に、関西エンジニアコミュニティを一緒に盛り上げてくださる方を募集しています。

よろしければ Conpass からメンバー登録よろしくお願いいたします。

https://blessingsoftware.connpass.com/

Discussion