2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ミューテーションテストでユニットテストの品質を上げる

Last updated at Posted at 2025-12-11

この記事はラクス Advent Calendar 2025の12日目の記事です

はじめに

チーム内でユニットテストの品質をめぐる議論があった際に、コードカバレッジの計測だけでは不十分だよね?という話になり、色々調べる中でmutation testingという考え方を知ったので検証も兼ねて手元の環境で試してみました。

ミューテーションテストとは

fault-based testingと呼ばれるテスト手法の一種です。
fault-basedとある通り、テスト対象のコードを書き換えてテストを実行し、テストが失敗することを期待値として確認する手法です。

テスト対象のコード
// 変更前
public bool isSumPositive(int $a, int $b){
    if ($a + $b) > 0 {
      return true;
    }
}

// ミュータント(テスト対象のコードにわざとバグを埋め込んだもの)生成
public bool isSumPositive(int $a, int $b){
    if ($a - $b) > 0 {
      return true;
    }
}
テストコード
test('合計が正ならtrueを返す', function () {
    expect(isSumPositive(3, -2))->toBeTrue();
});

上記のテストコードの品質は不十分です。
演算子を+から-に書き換えても、テスト結果が変わりません。
本来であれば、+で実装するべきところを-に実装ミスしたという状況を仮定すると、テストがfailするべきケースです。(=バグが検出できていない)

テストコード(修正後)
test('aが負でbが正のケース', function () {
    expect(isSumPositive(-1, 2))->toBeTrue();
});

修正後のテストでは、演算子が-の場合に結果が異なるためテストがfailします。

計算 結果
変更前 -1 + 2 = 1 > 0 true
ミュータント -1 - 2 = -3 > 0 false

このテストがあれば、もし誰かが+を-に書き間違えてもテストが落ちて気づけます。
ミューテーションテストでは、このように「バグを検出できるテストかどうか」を検証しています。

Infection:PHPでミューテーションテストを実現するためのライブラリ

Infectionというライブラリでミューテーションテストの実装が可能です。
テストフレームワークとしてPestを使っている場合は、ミューテーションテストが標準機能として使えるようなのでそれを使うとよさそうです。

今回は実際にリポジトリにInfectionを導入して使い心地を試してみたいと思います。

Infectionの基本的な使い方

今回はテスト対象コードとテストコードがすでに実装済みの前提で進めます。

テスト対象のコード

Calculator.php
<?php

declare(strict_types=1);

namespace App;

class Calculator
{
    /**
     * テストの点数で指定範囲内の人数をカウント
     * 例: 60点以上80点以下の生徒が何人いるか
     */
    public function countScoresInRange(array $scores, int $min, int $max): int
    {
        $count = 0;
        foreach ($scores as $score) {
            if ($score >= $min && $score <= $max) {
                $count++;
            }
        }

        return $count;
    }
}

テストコード

今回はPHPUnitで実装しています。
ドキュメントをよく読まずに初めはPestで進めていたのですが、どうやらサポートされていないようです。PHPUnit, PhpSpec, Codeceptionがサポート対象のようです。

CalculatorTest.php
<?php

declare(strict_types=1);

namespace App\Tests;

use App\Calculator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    private Calculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new Calculator();
    }

    /**
     * 指定範囲内の点数の人数を正しくカウントする
     */
    #[DataProvider('countScoresInRangeProvider')]
    public function testCountScoresInRange(array $scores, int $min, int $max, int $expected, string $description): void
    {
        $this->assertSame($expected, $this->calculator->countScoresInRange($scores, $min, $max), $description);
    }

    public static function countScoresInRangeProvider(): array
    {
        return [
            '65, 75の2人が範囲内' => [[55, 65, 75, 85, 95], 60, 80, 2, '65, 75の2人が範囲内'],
            '範囲外' => [[50, 55, 59], 60, 80, 0, '範囲外'],
            '空配列' => [[], 60, 80, 0, '空配列'],
        ];
    }
}

1. インストール

composer経由でインストールします。

composer require --dev infection/infection

今回はバージョンを0.31系に指定しました。

2. 設定ファイルの作成

The first time you run Infection for your project, it will ask you several questions to create a config file infection.json5, with the following structure:

設定ファイルとしてinfection.json5を用意する必要があります。
初回のInfection実行後に対話形式で回答していくと自動で生成されるそうです。

以下のコマンドで実行します。

./vendor/bin/infection

質問内容は以下の通りでした

(ミューテーションテストの対象とするソースコードのディレクトリはどれですか?)
Which source directories do you want to include (comma separated)? [src]: 

(ソースディレクトリの中で除外したいディレクトリはありますか?)
Any directories to exclude from within your source directories? []:

(PHPUnitの設定ファイルはどこにありますか?)
Where is your phpunit.(xml|yml)(.dist) configuration located? [.]: 

(テキストログファイルをどこに保存しますか?)
Where do you want to store the text log file? []:

全ての質問に答えると、infection.json5が作成されました。
※拡張子はjsonにしても問題ない

By default, Infection uses json5 format for configuration file. It allows using comments, ES5-like keys and many more. But if you for some reason can’t use it, Infection also supports json format.

infection.json5
{
    "$schema": "vendor/infection/infection/resources/schema.json",
    "source": {
        "directories": [
            "src"
        ]
    },
    "logs": {
        "text": "infection.log" // ログの吐き出し場所
    },
    "mutators": {
        "@default": true
    }
}

infection.json5は無事生成されましたが、warningが出ました。
coverage needs to be generated but no code coverage generatorだそうです。

In CoverageChecker.php line 90:
                                                                                                                                                      
  Coverage needs to be generated but no code coverage generator (pcov, phpdbg or xdebug) has been detected. Please either:                            
  - Enable pcov and run Infection again                                                                                                               
  - Use phpdbg, e.g. `phpdbg -qrr infection`                                                                                                          
  - Enable Xdebug (in case of using Xdebug 3 check that `xdebug.mode` or environment variable XDEBUG_MODE set to `coverage`) and run Infection again  
  - Use the "--coverage" option with path to the existing coverage report                                                                             
  - Enable the code generator tool for the initial test run only, e.g. with `--initial-tests-php-options -d zend_extension=xdebug.so` 
  - ```

pcov, phpdbg, xdebugなどが有効化されている必要があります。
デバッガーなしでも事前に生成したカバレッジを渡す方法もあるようですが、今回はxdebugを使うように設定しました。

3. infectionを実行する

設定ファイルの作成が完了したので、./vendor/bin/infectionコマンドを実行します。

実行結果は以下のようになりました。
ちなみにほぼ同じ内容のログがinfection.logに記録されています。(infection.json5で設定した通り)

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

#StandWithUkraine

Infection - PHP Mutation Testing Framework version 0.31.9


Running initial test suite...

PHPUnit version: 11.5.46

   10 [============================]    1 s

Generate mutants...

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

M.MM......                                           (10 / 10)
Escaped mutants:
================


1) /Users/miyawakiriku/development/infection-sandbox/src/Calculator.php:17    [M] GreaterThanOrEqualTo [ID] 7f1443ef53567ac900773613c04311ec

@@ @@
     {
         $count = 0;
         foreach ($scores as $score) {
-            if ($score >= $min && $score <= $max) {
+            if ($score > $min && $score <= $max) {
                 $count++;
             }
         }


2) /Users/miyawakiriku/development/infection-sandbox/src/Calculator.php:17    [M] LessThanOrEqualTo [ID] 9e365c446fa8c0c088b02ae2181cefc7

@@ @@
     {
         $count = 0;
         foreach ($scores as $score) {
-            if ($score >= $min && $score <= $max) {
+            if ($score >= $min && $score < $max) {
                 $count++;
             }
         }


3) /Users/miyawakiriku/development/infection-sandbox/src/Calculator.php:17    [M] LessThanOrEqualToNegotiation [ID] 4d1e2fef7445a9c0590e84b0792c97cc

@@ @@
     {
         $count = 0;
         foreach ($scores as $score) {
-            if ($score >= $min && $score <= $max) {
+            if ($score >= $min && $score > $max) {
                 $count++;
             }
         }



10 mutations were generated:
       7 mutants were killed by Test Framework
       3 covered mutants were not detected

Metrics:
         Mutation Code Coverage: 100%
         Covered Code MSI: 70%

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

Time: 1s. Memory: 16.00MB. Threads: 1

killできなかったミュータントが3つありました。
(※ミュータントに対してテストが失敗する(=バグを検出できる)ことを「ミュータントをkillする」と一般的に表現されます)

[M] GreaterThanOrEqualToがミュータントの生成ルール?のようですね。
境界値のテストが網羅されているかの検証のために、等号の付与と削除を行う定義がされています。
スクリーンショット 2025-12-12 0.38.55.png

Infection supports a set of Mutators which are based on AST and PHP-Parser project.

ドキュメントにこの説明がある通り、ミュータントの生成はAST(抽象構文木)とPHP-Parserを基盤としているそうです。
確かに内部でuse PhpParser\Node;が定義されています。
等号の変換くらいならロジックは比較的読みやすいですね。

4. テストケースを追加する

killできなかったミュータントがあるので、すべてkillするようにテストケースを追加・修正します。
Infectionはあくまで診断ツールとして存在するため、診断後のテストケースの追加は自力で進める必要があります。
とはいえ、infection.logをコーディングエージェントに投げて自動で修正させるhooksのような機構を作ればそこまでコストのかかる作業ではないかもしれません。

実際にClaude Codeに丸投げしてみました。
簡単なロジックなのでわざわざ検証するほどでもないかもしれませんが...

⏺ 了解です!Infectionを実行してエスケープしたミューテーションを確認します:

⏺ Bash(./vendor/bin/infection --show-mutations)
  ⎿      ____      ____          __  _           
        /  _/___  / __/__  _____/ /_(_)___  ____
        / // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \
     … +283 lines (ctrl+o to expand)

⏺ 完璧です!結果を確認できました。

  現在の状況:
  - MSI: 70% (7 killed / 3 escaped)
  - 3つのミューテーションがエスケープ

  エスケープしたミューテーション:
  1. $score >= $min$score > $min (境界値の下限)
  2. $score <= $max$score < $max (境界値の上限)
  3. $score <= $max$score > $max (論理エラー)

  問題: 境界値(minとmaxちょうどの値)をテストしていない

  解決策: 境界値を含むテストケースを追加します:

⏺ Update(tests/CalculatorTest.php)
  ⎿  Updated tests/CalculatorTest.php with 1 addition                                                      
       30        {
       31            return [
       32                '65, 75の2人が範囲内' => [[55, 65, 75, 85, 95], 60, 80, 2, '65, 75の2人が範囲内'],
       33 +              '境界値を含む' => [[60, 70, 80], 60, 80, 3, '境界値を含む'],
       34                '範囲外' => [[50, 55, 59], 60, 80, 0, '範囲外'],
       35                '空配列' => [[], 60, 80, 0, '空配列'],
       36            ];

⏺ 境界値テストを追加しました!まずPHPUnitで確認:

⏺ Bash(./vendor/bin/phpunit)
  ⎿  PHPUnit 11.5.46 by Sebastian Bergmann and contributors.

     Runtime:       PHP 8.5.0
     … +7 lines (ctrl+o to expand)

⏺ テスト通りました!次にInfectionを再実行してMSI 100%になることを確認:

⏺ Bash(./vendor/bin/infection)
  ⎿      ____      ____          __  _           
        /  _/___  / __/__  _____/ /_(_)___  ____
        / // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \
     … +239 lines (ctrl+o to expand)

⏺ 完璧です!MSI 100% 達成しました! 🎉

  改善結果の比較:

  | 指標      | Before(境界値テストなし) | After(境界値テスト追加) |
  |---------|------------------|-----------------|
  | MSI     | 70%              | 100%            |
  | Killed  | 7                | 10              |
  | Escaped | 3                | 0               |

  エスケープしていたミューテーション(すべて修正):
  1. ✅ $score >= $min$score > $min
  2. ✅ $score <= $max$score < $max
  3. ✅ $score <= $max$score > $max

  解決策:
  境界値テスト '境界値を含む' => [[60, 70, 80], 60, 80, 3] を追加することで、境界値(minとmaxちょうどの値)が正しく含まれることを確認し、すべてのミューテーションを検出できるようになりました。

境界値のテストケースが追加されました。
Infectionの実行→テスト修正までほぼ自動化できそうです。
ただ、エッジケースを上から下まで追加していたらケース数が一瞬で跳ね上がりそうな未来が見えるのでその辺りのハンドリングは検討の余地がありそうです。

開発フローにどう組み込むか?

前提

テストコードは仕様を表現する側面も持つため、レビュワーの立場からするとdiffにHoge.phpHogeTest.phpという対になる変更があったときにHogeTestのケースに漏れがないか?という観点を慎重に見ると思います。

CIにミューテーションテストが組み込まれていれば、対応するかどうかは別として、あいまいな仕様を表現できているか?というのを機械的に担保できるのではないでしょうか。

PR単位でdiffに対してミューテーションテストを実行する

InfectionはCIでの運用も想定されているためGitHub Actionsを用意します。

変更のあった行にのみミューテーションテストが実行されるようにしています。
おそらく、実務で運用するとなった場合はそれなりの数の生き残ったミュータントが発生してノイズになりそうです。
変更差分のみに集中したいので--git-diff-linesオプションを設定しました。

PRの内容は以下から確認できます。
killできないミュータントが発生するように網羅が不十分なテストコードを作ってCIを実行しました。期待通りに指摘をしてくれています。

スクリーンショット 2025-12-12 2.06.42.png

この後の運用はチームのテストに対する期待値や価値観によって変わるかもしれませんが、機械的にテストの品質をCIで検知できるのは個人的には嬉しいです。
(テストケース漏れの指摘はレビュワー側としても本当に必要か?と考えるコストがそれなりに発生するはずなので)

最後に

今回はInfectionの導入を通じてミューテーションテストを検証してみました。
ユニットテストの品質を上げるための手法の一つとして、こういったアプローチがあることを知れたのは勉強になったので良かったと思います。

ドキュメントでまだまだ拾いきれていない部分も多く、(特にmutationの生成ルールなど)開発フローに組み込む際のメリットやデメリットもまだまだありそうなので今後も学習したいと思います。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?