PHPでMutationTesting（Infection）をして、十分な単体テストを書く

MutationTestingってのが世の中的にどの程度一般的なのかはわかりませんが、会社的に微妙に盛り上がっていて、PHPでも実行できるがそこまで記事がなさそうなので書いてみます。

## MutationTestingとは

単体テストが十分に必要なだけ書けているかを測る手段で、単にテストの際にその部分を通っているかというカバレッジだけでなく、質的な面でもどうなのかがわかるものです。
コードに少し変更を加えてそれでも単体テストは成功するのか失敗するのか、コードに変更を与えたのだからテストは失敗するべきでそういうテストが書けているか、というのを判定していって質を判定します。

例えば、カバレッジは通っているかだけなので

```php
if ($a > 0) {
   // hoge
}
```

このコードで言えば$a = 3をテストすれば、単体テストは通るしカバレッジ的にもOKなわけですが、falseのテストパターンはなくて、

```php
if (true) {
    // hoge
}
```

と書き換えてもテスト全体を通して失敗がない（mutantが生き残る）ため質が低いのが分かる感じです。
それ以外にも、よく言う境界値テストならば上記は$a=1と0をテストがあるべきで、そういうところもMutationTestingでは洗い出せることがあります（どの言語のものでもそうなのかまでわからず。またどの程度書き換えるかというのも選べたりします）

他の言語だと

- Java：[Pitest](https://pitest.org/)
- JavaScript：[Stryker Mutator](https://stryker-mutator.io/)

が使われています。

## PHPのMutationTesting

PHPでのMutationTestingは「[Infection](https://infection.github.io/)」らしいです。
PHPUnitだけでなく、PhpSpec、Pest、Codeceptionなどにも対応しているらしいですが、老害なのでPHPUnitしかわかりません。
導入のバージョンとか必要なものはこちら↓を参照してください。

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

今回はPHP8.1で、Xdebugしか知らないのでphp-xdebugを利用します

## Infectionを試してみる

### composerで入れる

いろいろな入れ方が載ってますが、今回はcomposer.jsonのdependenciesDevに入るようにします。

```bash
$ composer require --dev infection/infection
```

### 適当なサンプルコード

参考にゴミのようなコードとゴミのような単体テストを用意しました。

- コード：https://github.com/miyawa-tarou/php_mutation/blob/main/src/Sample.php
- テスト：https://github.com/miyawa-tarou/php_mutation/blob/main/tests/Unit/SampleTest.php

### 初回実行

infection.json5を事前に用意してもよいですが、何も用意しなくても初回実行時は下記のような感じでinfection.json5が生成できます。

```bash
% ./vendor/bin/infection --threads=4

  Welcome to the Infection config generator

We did not find a configuration file. The following questions will help us to generate it for you.

Which source directories do you want to include (comma separated)? [src]:
  [0] .
  [1] src
  [2] tests
  [3] tmp
  [4] vendor
 > 1

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? []: tmp

Configuration file "infection.json5" was created.
```
そのまま実行され

```bash
____      ____          __  _
   /  _/___  / __/__  _____/ /_(_)___  ____
   / // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \
 _/ // / / / __/  __/ /__/ /_/ / /_/ / / / /
/___/_/ /_/_/  \___/\___/\__/_/\____/_/ /_/

#StandWithUkraine

Infection - PHP Mutation Testing Framework version 0.26.16

Running initial test suite...

PHPUnit version: 9.5.27

    9 [============================] 4 secs

Generate mutants...

Processing source code files: 1/1
.: killed, M: escaped, U: uncovered, E: fatal error, X: syntax error, T: timed out, S: skipped, I: ignored

UUM..MM...MMMM..M......                              (23 / 23)

23 mutations were generated:
      13 mutants were killed
       0 mutants were configured to be ignored
       2 mutants were not covered by tests
       8 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): 56%
         Mutation Code Coverage: 91%
         Covered Code MSI: 61%

Generated Reports:
         - tmp/infection.log

Please note that some mutants will inevitably be harmless (i.e. false positives).

Time: 19s. Memory: 18.00MB
```

そして、結果のtmp/infection.log（infection.json5作成時に指定したもの）が生成されます

## Infectionの実行結果

上の結果の数値を見ると、23のmutationのうち、13はうまく殺したが2はそもそもテストのカバレッジ不足で生き残り、8つも生き残った。結果56％という感じです。
さらにその詳細としてinfection.logを確認します

```
1) /mnt/c/Users/webma/PhpstormProjects/php_mutation/src/Sample.php:8    [M] Exponentiation

--- Original
+++ New
@@ @@
 {
     public static function sample1(int $a, int $b) : int
     {
-        return $a ** $b;
+        return $a / $b;
     }
     public static function sample2(int $a, int $b) : bool
     {

```

**を/に書き換えたことがわかります。
$a=$b=1のテストのために偶然一致してしまって引っかかります。
まあこれはそもそもサンプルコードがイケてなくて、引っかかるためのテストを書いている感じも否めないですが。

```bash
2) /mnt/c/Users/webma/PhpstormProjects/php_mutation/src/Sample.php:13    [M] GreaterThan

--- Original
+++ New
@@ @@
     }
     public static function sample2(int $a, int $b) : bool
     {
-        if ($a * $b >10) {
+        if ($a * $b >=10) {
             returntrue;
         }
         returnfalse;
```

10のテストをすればtrue false入れ替わるのに、ないので引っかかります。
境界値テストと同じですね。

```bash
Not Covered mutants:
====================

1) /mnt/c/Users/webma/PhpstormProjects/php_mutation/src/Sample.php:16    [M] FalseValue

--- Original
+++ New
@@ @@
         if ($a * $b > 10) {
             return true;
         }
-        return false;
+        return true;
     }
     public static function sample3(int $a, int $b) : bool
     {
```

カバレッジがないのも発見できます

## 結果をHTMLで見やすくする

結果にHTMLも出力するようにすれば、こんな感じでHTMLから確認できます。JSのStrykerMutatorのUIと同じものです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/140947/ba634336-e00f-71a6-75fe-71e72f8ba7a0.png)

コマンドの引数指定でもHTMLを出力できます。

`--logger-html='mutation-report.html`

infection.json5を変更してもOKです

https://github.com/miyawa-tarou/php_mutation/blob/8c589da15c0850facebc87ab6234bd5523eb24cf/infection.json5#L10

```diff_json
    "logs": {
-        "text": "tmp/infection.log"
+        "text": "tmp/infection.log",
+        "html": "tmp/infection.html"
    },
```

## 単にカバレッジ100%のテスト

先程の状態ではLINEカバレッジこそ100%でしたが、ほかがダメでした。
というので下記のように変えてみます。

https://github.com/miyawa-tarou/php_mutation/commit/1d73e6a79381d14191e5da609f88354865002efe

するとPHPUnitでは見事カバレッジ100%です。
単体テストを書き慣れている方にはこのテストじゃよくないのはわかるでしょうけど、よく使われるカバレッジの指標ではよいスコアが出てしまいます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/140947/c68cad5f-4494-f758-095e-f7d794b332e9.png)
しかしMutationTesting的には73%↓

```bash
.: killed, M: escaped, U: uncovered, E: fatal error, X: syntax error, T: timed out, S: skipped, I: ignored

M..M....M.M.M...M......                              (23 / 23)

23 mutations were generated:
      17 mutants were killed
       0 mutants were configured to be ignored
       0 mutants were not covered by tests
       6 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): 73%
         Mutation Code Coverage: 100%
         Covered Code MSI: 73%

Generated Reports:
         - tmp/infection.log
         - tmp/infection.html

Please note that some mutants will inevitably be harmless (i.e. false positives).

Time: 55s. Memory: 18.00MB
```

## MutationTestingの結果を改善する

MutationTestingで突っ込まれたところを改善します。

https://github.com/miyawa-tarou/php_mutation/commit/cf01b7fbc95a38c57361c62731fd091902189a0a

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/140947/d24c86cf-55c7-b9b0-40ed-e59bca272944.png)
基本的に境界値であったり、`||` 条件なら片方だけの場合もちゃんと考えることなど、基本的なテストをしっかり書けば基本的には網羅できます。それをこれまでのカバレッジでは表現しきれなくて、MutationTestingのスコアも高いからこそ単体テストがしっかり書けてると言えるのではないでしょうか。（完璧ではないとは思いますが、微妙な状態でも100%頭打ちになるカバレッジよりは良い指標）

## GitHub Actionsで動かす

単に動かすだけなら、上のコマンドをGitHub Actionsで実行できるようにすればよいだけですが、InfectionはGitHubのPR周りで色々行うことができます。

例えば、こんな感じでPullRequestでの差分のところに問題点を表示することができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/140947/1b944037-91b7-7e65-9564-570a77791136.png)
入れ方は下記。

https://github.com/miyawa-tarou/php_mutation/blob/8c589da15c0850facebc87ab6234bd5523eb24cf/.github/workflows/pr-infection.yml#L19-L22

GitHub ActionsのPullRequestでの実行の際に infectionの引数で特別なものを使う感じになります。


```yaml
- name: Run Infection for modified line only
        run: |
          git fetch --depth=1 origin $GITHUB_BASE_REF
          vendor/bin/infection --git-diff-lines --git-diff-base=origin/$GITHUB_BASE_REF --logger-github --ignore-msi-with-no-mutations --only-covered
```

`--git-diff-lines` の代わりに `—git-diff-filter=MA` を使うと追加変更ファイルが対象になる模様
`--logger-github` があると上のようにコードのところに書き込まれる
`--ignore-msi-with-no-mutations` によって、mutation対象がなかった場合もエラーにならないようになる

詳しくは：[https://infection.github.io/guide/command-line-options.html](https://infection.github.io/guide/command-line-options.html)

## 一定のスコア以下なら失敗にする

`--min-msi` `--min-covered-msi` を使うと失敗にすることができます。

https://infection.github.io/guide/using-with-ci.html

```diff_yaml
+ vendor/bin/infection --min-msi=80
```
https://github.com/miyawa-tarou/php_mutation/blob/411e1d47eec43c77a2dcd050ba1ced3cd6dfb225/.github/workflows/pr-infection.yml#L21

以下のようなエラーになります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/140947/60d8b36e-8ec9-1129-2fd9-c9066347cced.png)
参考：https://github.com/miyawa-tarou/php_mutation/pull/3

試してないですが、差分だけに絞って実行してしまうとその部分だけでのスコアになると思われるので、低いスコアにならざるをえないときなどに期待しない失敗となる可能性はあると思います。
どのくらいのスコアが適切かというのは難しい問題です。言語や使う場所にもよって異なるようですし、難しいところですが、80%とか適当においてそれを維持する、そこまで上げていくようにしていくのが良いのではないかと思います。（ただしスコアを上げることが目的ではない）

## Infectionが期待どおりに動かないところ

- 単体テストを実装しないで動かすと、差分が無視される気がする（？）
- テストだけ変更して差分を実行しようとしても対象ファイルは差分に含まれない

## 高速化で期待したいこと

MutationTestingは単体テスト×コードの書き換えの分だけ回す必要があるので、時間がかかります。これはどの言語のテストでも同じです。
上の例でもPHPUnitは2秒程度で終わっていますが、MutationTestingは20秒～くらいかかっています。
並列処理を増やすと速くはなりますが、CPUのコア・スレッドの限界はあります。

幸いInfectionはPRでの実行に関しては上記のように差分のあるファイルだけを実行するのがすでに整っているため、そこの時間に困ることはないでしょうが、全ファイルで動かすときなどは場合によっては時間がかかってしまいます。

深夜に自動実行させてごまかしたり、ファイルを分割して実行するHack的なこともあるのですが、他の言語で実装されていることとしては前の実行時のキャッシュを使い、差分実行することで高速化するというのがあります。（１回目はどうしても時間がかかりますが）

- https://pitest.org/quickstart/incremental_analysis/
- https://stryker-mutator.io/docs/stryker-js/incremental/

このあたりは３年くらい前にissueにありましたが、実現できていないようです。

https://github.com/infection/infection/issues/1085

とはいえ、Strykerに導入されたのもつい先日2022年9月で、いまこのあたりは進歩しているところだと思うので期待して待ちたいと思います。
https://stryker-mutator.io/blog/announcing-incremental-mode/

## 最後に

単体テストがあれば、簡単に導入できるのがMutationTestingの良いところです。
今日日まさか単体テストがないはずもないと思いますし（煽り）、PHPに限らず是非MutationTestingを導入してほしいと思います。
コードレビューの指摘などで単体テストの書き方を学ぶ、書き方の悪さに気づくことは多々あると思いますが、これはレビュアーの能力にも依存しますし漏れもあるものです。そういったときにレビュアーに依存せずに指摘できる仕組みとして用いることで、コードの保守もできるし成長にもつながるものだと思います。