MutationTestingってのが世の中的にどの程度一般的なのかはわかりませんが、会社的に微妙に盛り上がっていて、PHPでも実行できるがそこまで記事がなさそうなので書いてみます。
MutationTestingとは
単体テストが十分に必要なだけ書けているかを測る手段で、単にテストの際にその部分を通っているかというカバレッジだけでなく、質的な面でもどうなのかがわかるものです。
コードに少し変更を加えてそれでも単体テストは成功するのか失敗するのか、コードに変更を与えたのだからテストは失敗するべきでそういうテストが書けているか、というのを判定していって質を判定します。
例えば、カバレッジは通っているかだけなので
if ($a > 0) {
// hoge
}
このコードで言えば$a = 3をテストすれば、単体テストは通るしカバレッジ的にもOKなわけですが、falseのテストパターンはなくて、
if (true) {
// hoge
}
と書き換えてもテスト全体を通して失敗がない(mutantが生き残る)ため質が低いのが分かる感じです。
それ以外にも、よく言う境界値テストならば上記は$a=1と0をテストがあるべきで、そういうところもMutationTestingでは洗い出せることがあります(どの言語のものでもそうなのかまでわからず。またどの程度書き換えるかというのも選べたりします)
他の言語だと
- Java:Pitest
- JavaScript:Stryker Mutator
が使われています。
PHPのMutationTesting
PHPでのMutationTestingは「Infection」らしいです。
PHPUnitだけでなく、PhpSpec、Pest、Codeceptionなどにも対応しているらしいですが、老害なのでPHPUnitしかわかりません。
導入のバージョンとか必要なものはこちら↓を参照してください。
今回はPHP8.1で、Xdebugしか知らないのでphp-xdebugを利用します
Infectionを試してみる
composerで入れる
いろいろな入れ方が載ってますが、今回はcomposer.jsonのdependenciesDevに入るようにします。
$ 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が生成できます。
% ./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.
そのまま実行され
____ ____ __ _
/ _/___ / __/__ _____/ /_(_)___ ____
/ // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \
_/ // / / / __/ __/ /__/ /_/ / /_/ / / / /
/___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/
#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のテストのために偶然一致してしまって引っかかります。
まあこれはそもそもサンプルコードがイケてなくて、引っかかるためのテストを書いている感じも否めないですが。
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入れ替わるのに、ないので引っかかります。
境界値テストと同じですね。
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と同じものです。
コマンドの引数指定でもHTMLを出力できます。
--logger-html='mutation-report.html
infection.json5を変更してもOKです
"logs": {
- "text": "tmp/infection.log"
+ "text": "tmp/infection.log",
+ "html": "tmp/infection.html"
},
単にカバレッジ100%のテスト
先程の状態ではLINEカバレッジこそ100%でしたが、ほかがダメでした。
というので下記のように変えてみます。
するとPHPUnitでは見事カバレッジ100%です。
単体テストを書き慣れている方にはこのテストじゃよくないのはわかるでしょうけど、よく使われるカバレッジの指標ではよいスコアが出てしまいます。
しかしMutationTesting的には73%↓
.: 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で突っ込まれたところを改善します。
基本的に境界値であったり、||
条件なら片方だけの場合もちゃんと考えることなど、基本的なテストをしっかり書けば基本的には網羅できます。それをこれまでのカバレッジでは表現しきれなくて、MutationTestingのスコアも高いからこそ単体テストがしっかり書けてると言えるのではないでしょうか。(完璧ではないとは思いますが、微妙な状態でも100%頭打ちになるカバレッジよりは良い指標)
GitHub Actionsで動かす
単に動かすだけなら、上のコマンドをGitHub Actionsで実行できるようにすればよいだけですが、InfectionはGitHubのPR周りで色々行うことができます。
例えば、こんな感じでPullRequestでの差分のところに問題点を表示することができます。
入れ方は下記。
GitHub ActionsのPullRequestでの実行の際に infectionの引数で特別なものを使う感じになります。
- 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
一定のスコア以下なら失敗にする
--min-msi
--min-covered-msi
を使うと失敗にすることができます。
+ vendor/bin/infection --min-msi=80
以下のようなエラーになります
参考:https://github.com/miyawa-tarou/php_mutation/pull/3
試してないですが、差分だけに絞って実行してしまうとその部分だけでのスコアになると思われるので、低いスコアにならざるをえないときなどに期待しない失敗となる可能性はあると思います。
どのくらいのスコアが適切かというのは難しい問題です。言語や使う場所にもよって異なるようですし、難しいところですが、80%とか適当においてそれを維持する、そこまで上げていくようにしていくのが良いのではないかと思います。(ただしスコアを上げることが目的ではない)
Infectionが期待どおりに動かないところ
- 単体テストを実装しないで動かすと、差分が無視される気がする(?)
- テストだけ変更して差分を実行しようとしても対象ファイルは差分に含まれない
高速化で期待したいこと
MutationTestingは単体テスト×コードの書き換えの分だけ回す必要があるので、時間がかかります。これはどの言語のテストでも同じです。
上の例でもPHPUnitは2秒程度で終わっていますが、MutationTestingは20秒~くらいかかっています。
並列処理を増やすと速くはなりますが、CPUのコア・スレッドの限界はあります。
幸いInfectionはPRでの実行に関しては上記のように差分のあるファイルだけを実行するのがすでに整っているため、そこの時間に困ることはないでしょうが、全ファイルで動かすときなどは場合によっては時間がかかってしまいます。
深夜に自動実行させてごまかしたり、ファイルを分割して実行するHack的なこともあるのですが、他の言語で実装されていることとしては前の実行時のキャッシュを使い、差分実行することで高速化するというのがあります。(1回目はどうしても時間がかかりますが)
- https://pitest.org/quickstart/incremental_analysis/
- https://stryker-mutator.io/docs/stryker-js/incremental/
このあたりは3年くらい前にissueにありましたが、実現できていないようです。
とはいえ、Strykerに導入されたのもつい先日2022年9月で、いまこのあたりは進歩しているところだと思うので期待して待ちたいと思います。
https://stryker-mutator.io/blog/announcing-incremental-mode/
最後に
単体テストがあれば、簡単に導入できるのがMutationTestingの良いところです。
今日日まさか単体テストがないはずもないと思いますし(煽り)、PHPに限らず是非MutationTestingを導入してほしいと思います。
コードレビューの指摘などで単体テストの書き方を学ぶ、書き方の悪さに気づくことは多々あると思いますが、これはレビュアーの能力にも依存しますし漏れもあるものです。そういったときにレビュアーに依存せずに指摘できる仕組みとして用いることで、コードの保守もできるし成長にもつながるものだと思います。