コドモン Product Team Blog

株式会社コドモンの開発チームで運営しているブログです。エンジニアやPdMメンバーが、プロダクトや技術やチームについて発信します!

PHPUnit のテストダブルと仲良くなりたい(モック編)

こちらは「コドモン Advent Calendar 2025」の22日目の記事です。

tech.codmon.com

こんにちは、プロダクト開発部のふくいです。

昨年の「コドモン Advent Calendar 2024」で以下の記事を書いたのですが、

tech.codmon.com

一年越しに続編として PHPUnit でモックを使ったテストコードについて、自分なりにサンプルコードを交えてまとめてみました。(他に書くネタが思い浮かばなかったとも言います、、💦

なお、記事中のサンプルコードは以下にまとめています。

また、記載したコードは PHP 8.2-8.5 と PHPUnit 11.5 の環境で動作確認しています。

github.com

PHPUnit におけるスタブとモックの違い

モックを使ったテストコードを書く前に、PHPUnit におけるスタブとモックの違いのイメージを、自分なりに簡単な図にしてみました。

スタブとモックの違いのイメージ

スタブがテスト対象の依存先が返す値を特定の値に置き換える機能を提供しているのに対して、モックはスタブの機能に加えて、テスト対象が依存先をどのように呼び出しているかを明示的に「検証」する機能を提供しています。

モックがスタブの機能を内包しているなら、モックだけ使えればよいのでは..という気持ちになりますが、PHPUnit ではそれぞれを別の機能として提供しています。理由はマニュアル内のモックオブジェクトの冒頭*1でも示されていますが、両者は根本的に別の用途で使われる機能であるため、テストコードの書き手の意図を読み手に明確に伝えるための手段を提供している、ということかと思いました。この辺りは作っている方々の思想が強く伝わってきます。

モックを使ったテストコードの例

昨年使用したサンプルコードに対するモックを使った素直なテストを書いてみます。テスト対象は Calculator クラス、依存先のモック対象は ApiGateway インタフェースです。

createMock() でモックを生成し、method() で対象のメソッドを指定し、willReturn() でその返却値を何らか指定します。ここまではスタブを利用したテストコードと同じですが、合わせて invoke メソッドが 1 回だけ呼び出されることと、その引数が nameapple であることを確認します。ここがモックで検証しているところになります。

<?php

declare(strict_types=1);

namespace PHPUnitExamples\TestDoubles;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(Calculator::class)]
final class CalculatorMockTest extends TestCase
{
    public function testAmountOfProductUsedExpectsAndWith(): void
    {
        $api = $this->createMock(ApiGateway::class);

        $api->expects($this->once())
            ->method('invoke')
            ->with(
                'name',
                'apple',
            )
            ->willReturn('220');

        (new Calculator($api))
            ->amountOfProduct('apple', 3);
    }
}

createMock() で作成した MockObject クラスには expects() というメソッドがあり、引数に TestCase クラスで提供されている Matcher を返却するメソッドを指定することで、モックオブジェクトをテスト対象のオブジェクトが呼び出した回数を検証することができます。

メソッド 検証内容
any() 0回以上実行された
never() 1回も実行されなかった
atLeastOnce() 少なくとも1回実行された
once() 1回だけ実行された
atMost(int $count) 最大で $count の回数だけ実行された
exactly(int $count) ちょうど $count の回数だけ実行された

expects() が返却する InvocationMocker クラスには with() というメソッドがあり、モックオブジェクトのメソッドの引数に指定された値を検証することができます。 例に書いた様に単純に一致することを検証するために直接それぞれの値を指定してもいいのですが、引数に TestCase クラスで提供されている Constraints を指定することで、さらにさまざまな値の検証を行うことができます。

<?php
// ...
    public function testAmountOfProductUsedExpectsAndWithConstraints(): void
    {
        $api = $this->createMock(ApiGateway::class);

        $api->expects($this->once())
            ->method('invoke')
            ->with(
                $this->identicalTo('name'),
                $this->stringContains('apple'),
            )
            ->willReturn('220');

        (new Calculator($api))
            ->amountOfProduct('apple', 3);
    }
メソッド 検証内容
anything() 任意の入力値
equalTo($value) $value の値と等しい(==)
identicalTo($value) $value の値と厳密に等しい(===)
matchesRegularExpression($pattern) 正規表現 $pattern と一致する
stringContains($string, $case) $string の値が含まれている*2
fileExists() ファイルが存在している

モックについての基本的な整理はここまでになります。

気になること

書いていると、こんなときはどう書くの?と気になってきます。

final がついたクラスやメソッドを何とかスタブ・モックしたいのだけれど..

final のついたクラスをスタブ・モックにしようとした場合、以下のメッセージが表示され、生成に失敗します。

PHPUnit\Framework\MockObject\Generator\ClassIsFinalException: Class XXX is declared "final" and cannot be doubled

これは PHPUnit がスタブ・モックの生成について、対象のクラスを継承したクラスを動的に生成することで実現しているためです。

PHP の仕様上は対応する方法がなさそうですが、回避する方法はいくつかありそうです。方法の一つとして、導入には一定のリスクはありますが DG\BypassFinals を使って、ユニットテストの実行時にだけ動的にテスト対象のクラスから final を取り除く、というものがあります。

詳細についてはコードリポジトリや、

github.com

以下などいくつか解説されている記事があります。

tomasvotruba.com

テスト対象クラス内にあるメソッドをスタブ・モックしたい場合はどう書くといい?

方法はいくつかありそうですが、手軽に対応するにはパーシャルモックを使うとよさそうです。

パーシャルモックは、モック対象のクラスの一部のメソッドだけをモック化し、残りのメソッドは実際の実装をそのまま使うモックのことで、既存のコードの変更を最小限に留めつつテストコードを追加したい場合などでよく使われる方法です。

テスト対象の Calculator クラスを少し書き換えて、依存先の ApiGateway::invoke() を直接呼び出すのではなく、内部のメソッド invoke() を経由して呼び出すようにしてみます。

<?php
// ...
    public function amountOfProduct(string $name, int $quantity): int
    {
        $priceString = $this->invoke('name', $name);  // $this->api->invoke() を書き換え

        if (is_numeric($priceString) === false) {
            throw new InvalidArgumentException('Price must be a number, but ' . $priceString);
        }

        $unitPrice = intval($priceString);

        return $this->amount($unitPrice, $quantity);
    }

    // $this->api->invoke() を呼び出すメソッドを内部で用意する
    /**
     * @throws NotFoundException
     */
    protected function invoke(string $name, string $value): string
    {
        return $this->api->invoke($name, $value);
    }

createPartialMock() で依存先の ApiGateway ではなく、テスト対象の Calculator クラスのパーシャルモックを生成して、内部のメソッド invoke() のみをモックに差し替えることができます。

<?php
// ...
    public function testAmountOfProductUsedPartialMock(): void
    {
        $calculator = $this->createPartialMock(Calculator::class, ['invoke']);

        $calculator->expects($this->any())
            ->method('invoke')
            ->with(
                $this->identicalTo('name'),
                $this->stringContains('banana'),
            )
            ->willReturn('40');

        $calculator
            ->amountOfProduct('banana', 16);
    }

ただ、createPartialMock() については、まだ実際には適用されていなさそうですが、以下などで非推奨にする動きがあったり、

github.com

private なメソッドについてはそのままではモックにできないため、アドホックにテストコード内で継承したモッククラスを作って上書きするなどの選択もありそうです。

まとめ

PHPUnit のモックについて簡単なサンプルコードを書きながらまとめてみました。

より高機能な専用のモックライブラリがたくさんある中で PHPUnit のスタブやモックを使う機会はあまりないかもしれないのですが、一般的なユニットテストを書く上で十分な機能を持っていると改めて感じました。

なお、今回の記事を書いている中で、改めて PHPUnit のバージョンごとの変更履歴を確認していたのですが、PHPUnit の内部構造をシンプルに保つための過去機能の積極的な削除や、PHPUnit を利用するアプリケーションにいい設計を促すための仕様変更が積極的に行われていて、これほど身近で多く使われているソフトウェアが現在も継続して変化し続けていることに、感謝と敬意、また長期メンテナンスする上での思い切った変更の決断を下す勇気の大切さを感じました。

最後まで読んでいただきありがとうございました!

*1:https://docs.phpunit.de/en/11.5/test-doubles.html#mock-objects

*2: $case には英大文字・小文字を区別したい場合に false を指定します。既定値は true (区別しない) です。