こんにちは、Nakaです。

ミューテーションテストを導入していても、テスト漏れによる不具合を見逃してしまうケースがあります。本記事では、Infection の標準ミューテータでは検出できなかった不具合に対して、カスタムミューテータを追加することで再発防止につなげた事例を紹介します。

具体例として、Laravel Collection の groupBy メソッドを使った 会議室予約の重複チェック を取り上げ、実際に どのようなミューテーションが不足していたのか、そして それをどう補ったのかを解説します。この例を通して、Infection の カスタムミューテータの作り方と、実務での使いどころを理解できる構成になっています。

なお、Infection の基本的な使い方(概要・インストール・実行方法)については既に理解している前提で進めます。まだ触れたことがない方は、以下の記事を先に読んでおくとスムーズです。

PHPUnitとPHP Infectionを用いたテスト


なぜ必要になったのか:テストがあっても見逃されたバグ

会議室予約の重複チェック

例として、会議室予約システムを考えます。このシステムには、次のような仕様があります。

同じ会議室に同じ時間帯の予約は入れられない

まずは、この仕様を満たすためのドメインクラスを定義します。

class Reservation
{
    public function __construct(
        public int $roomId,
        public Carbon $start,
        public Carbon $end,
    ) {}

    public function overlaps(self $other): bool
    {
        return $this->start->lt($other->end)
            && $other->start->lt($this->end);
    }
}

続いて、予約の一覧を受け取り、重複があるかどうかを判定するクラスを実装します。

class ReservationOverlapChecker
{
    /**
     * @param Collection<int, Reservation> $reservations
     */
    public function hasOverlappingReservations(Collection $reservations): bool
    {
        return $reservations
            ->groupBy('room_id')
            ->some(function (Collection $reservations) {
                return $reservations->somePair(fn (Reservation $res1, Reservation $res2) => $res1->overlaps($res2));
            });
    }
}

ここでは、まず groupBy で会議室ごとに予約を分け、その中で 同じ会議室内の予約同士が重なっていないかをチェックしています。

<aside> 💡

somePair は Collection に追加した独自メソッドで、「要素のすべての組み合わせに対してコールバックを適用し、1つでも true になれば true を返す」ものです。

また、 ReservationOverlapChecker クラスのテストコードも作成します。

public function test_has_overlapping_reservations(): void
{
    $reservations = collect([
        new Reservation(
            roomId: 1,
            start: Carbon::parse('2025-01-01 10:00'),
            end: Carbon::parse('2025-01-01 11:00'),
        ),
        new Reservation(
            roomId: 1,
            start: Carbon::parse('2025-01-01 10:30'),
            end: Carbon::parse('2025-01-01 12:00'),
        ),
    ]);

    $checker = new ReservationOverlapChecker();

    $this->assertTrue(
        $checker->hasOverlappingReservations($reservations)
    );
}

public function test_no_overlapping_reservations(): void
{
    $reservations = collect([
        new Reservation(
            roomId: 1,
            start: Carbon::parse('2025-01-01 10:00'),
            end: Carbon::parse('2025-01-01 11:00'),
        ),
        new Reservation(
            roomId: 1,
            start: Carbon::parse('2025-01-01 11:00'),
            end: Carbon::parse('2025-01-01 12:00'),
        )
    ]);

    $checker = new ReservationOverlapChecker();

    $this->assertFalse(
        $checker->hasOverlappingReservations($reservations)
    );
}

これらのテストはすべて成功し、コードカバレッジも 100%、Infection を実行しても生成されたミューテーションはすべて検出されました。一見すると、この実装は問題なさそうに見えます。

しかし、実はバグがあります