コドモン Product Team Blog

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

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

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

qiita.com

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

私は業務の中で主に PHP と向き合っている毎日なのですが、先日所属しているチームで普段通り会話しながらペアでテストコードを書いていたところで、ふと気がついてしまいました。

  • 「あれ、PHPUnit で引数に応じて返却値を変えるスタブってどう書けばいいんだっけか..」

  • 「いや、そもそも PHPUnit でスタブやモックをどう書けば使える様になるのか自分全然わかってない..」

自分がなにもわからないことを思い知らされるペアプロのプラクティスと同僚に感謝しつつ、もう何百番煎じになるのかもわかりませんが、PHPUnit でスタブを使ったテストコードについて、自分なりにサンプルコードを交えてまとめてみました。

以下、記載したコードは PHP 8.1-8.4 と PHPUnit 10.5 の環境で動作確認しています。

準備

とても簡易なものですが、テスト対象として以下の様なコードを準備してみます。

  • Calculator.php
<?php

declare(strict_types=1);

namespace PHPUnitExamples;

use InvalidArgumentException;

final class Calculator
{
    public function __construct(
        private readonly ApiGateway $api,
    ) {
    }

    /**
     * @throws NotFoundException
     */
    public function amountOfProduct(string $name, int $quantity): int
    {
        $priceString = $this->api->invoke('name', $name);

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

        $unitPrice = intval($priceString);

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

    private function amount(int $unitPrice, int $quantity): int
    {
        return $unitPrice * $quantity;
    }
}

指定した商品と数量を指定すると、何だか内部で API らしきものを呼び出して商品の単価を取得し、単価と数量を掛け合わせて合計金額を返すメソッドを持っています。こちらが今回のテスト対象のメソッドです。

  • ApiGateway.php
<?php

declare(strict_types=1);

namespace PHPUnitExamples;

interface ApiGateway
{
    /**
     * @throws NotFoundException
     */
    public function invoke(string $name, string $value): string;
}

こちらは先ほどの amountOfProduct() の内部で呼び出されている API のインタフェース定義です。引数を二つ取り、キーの名前と値を文字列で指定すると単価を文字列で返します。存在しない商品を指定すると例外を返します。こちらが今回のスタブ対象です。

  • NotFoundException.php
<?php

declare(strict_types=1);

namespace PHPUnitExamples;

use Exception;

final class NotFoundException extends Exception
{
}

商品が見つからない場合に ApiGateway からスローされる例外クラスです。

これらのコードを前提として、PHPUnit で用意されているスタブ関連の機能をマニュアルを見ながら書いてみます。

スタブの生成

何はともあれ一番素直なテストをまずは一つ書いてみます。

createStub() でスタブを生成し、method() で対象のメソッドを指定し、willReturn() でその返却値を指定し、りんごが 3 つで 660 円になることを確認します。

<?php

declare(strict_types=1);

namespace PHPUnitExamples;

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

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

        $api->method('invoke')
            ->willReturn('220');

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

スタブの生成に関しては、生成と同時に複数のメソッドの固定の返却値をまとめて設定できる createConfiguredStub() というメソッドが用意されています。便利メソッド扱いな感じでしょうか。

<?php
...
    public function testAmountOfProductUsedCreateConfiguredStub(): void
    {
        $api = $this->createConfiguredStub(
            ApiGateway::class,
            [
                'invoke' => '220',
            ]
        );

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

他に交差型のクラスのスタブを生成するための createStubForIntersectionOfInterfaces() もあるそうなのですが、必要になったときに使えば良い範囲かと思いまして、今回はスキップしました。

スタブの設定

どういうときにどんな値を返して欲しいか、ということを設定するために用意されているいくつかのメソッドを使ってみます。

willReturn()

上記で出てきた固定の返却値を設定する書き方以外に、複数の引数を指定することで、指定した順番にそれぞれの返却値を返すこともできます。

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

        $api->method('invoke')
            ->willReturn('220', '40', '420');

        $this->assertSame(
            660,
            (new Calculator($api))
                ->amountOfProduct('apple', 3)
        );

        $this->assertSame(
            640,
            (new Calculator($api))
                ->amountOfProduct('banana', 16)
        );

        $this->assertSame(
            1680,
            (new Calculator($api))
                ->amountOfProduct('peach', 4)
        );
    }

ちなみにこの状態で 4 つめの assertSame() を追加すると、返却値が 3 つしか指定されていない旨のエラーになります。

willThrowException()

特定の例外をスローする設定ができます。

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

        $api->method('invoke')
            ->willThrowException(new NotFoundException());

        $this->expectException(NotFoundException::class);

        (new Calculator($api))
            ->amountOfProduct('grape', 5);
    }

今回のサンプルコードでは全く意味のないテストですが、例外発生後の処理を検証したい場合など使用場面は多そうです。

willReturnArgument()

指定した引数の添字(0 始まり)の値をそのまま返却する設定ができます。 下の例はかなり無理やりなテストですが、amountOfProduct() の第一引数 → invoke() の第二引数の値をそのまま返却しています。

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

        $api->method('invoke')
            ->willReturnArgument(1);

        $this->assertSame(
            1260,
            (new Calculator($api))
                ->amountOfProduct('420', 3)
        );
    }

こちらは特に利用したい場面を思いつかなかったのですが、よい活用シーンがあれば教えていただきたいです。

willReturnCallback()

スタブ対象の引数を受け取るコールバック関数を指定して、引数に応じた返却値を自由に設定できます。 match 式と組み合わせると、いい感じに引数に応じた返却値を設定できます。

<?php
...
    #[TestWith(['apple', 3, 660])]
    #[TestWith(['banana', 16, 640])]
    #[TestWith(['peach', 4, 1680])]
    public function testAmountOfProductUsedWillReturnCallback(string $productName, int $quantity, int $expected): void
    {
        $api = $this->createStub(ApiGateway::class);

        $api->method('invoke')
            ->willReturnCallback(function (string $name, string $value) {
                return match ($value) {
                    'apple' => '220',
                    'banana' => '40',
                    'peach' => '420',
                    default => throw new NotFoundException(),
                };
            });

        $actual = (new Calculator($api))
            ->amountOfProduct($productName, $quantity);

        $this->assertSame($expected, $actual);
    }

ペアでテストコードを書いていた際にも、これいいな、ってなりました。

willReturnMap()

引数と返却値を [引数1, 引数2, ..., 返却値] の形式でセットで指定できます。あらかじめ決まっている返却値の組み合わせを返却したい場合には、テストを過剰に複雑にしないためにこちらが活用できそうです。

 <?php
...
    #[TestWith(['apple', 3, 660])]
    #[TestWith(['banana', 16, 640])]
    #[TestWith(['peach', 4, 1680])]
    public function testAmountOfProductUsedWillReturnMap(string $productName, int $quantity, int $expected)
    {
        $api = $this->createMock(ApiGateway::class);

        $api->method('invoke')
            ->willReturnMap([
                ['name', 'apple', '220'],
                ['name', 'banana', '40'],
                ['name', 'peach', '420'],
            ]);

        $actual = (new Calculator($api))
            ->amountOfProduct($productName, $quantity);

この他にも willReturnSelf() がある様なのですが、具体的な使い道がいまいち思い浮かばなかったのでスキップしました。

気になったこと

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

返却値が void のメソッドのスタブはどう書くの?

何も書かないことで良さそうです。

インタフェース定義に void を返す適当なメソッドを追加して、

<?php
...
    public function none(): void;

テストコードでスタブを生成して、直接追加したメソッドを呼び出せること、何も起こらないことを確認してみました。 (アサーションしていないため、合わせて DoesNotPerformAssertions 属性を付与しています)

<?php
...
    #[DoesNotPerformAssertions]
    public function testAmountOfProductReturnVoid(): void
    {
        $api = $this->createStub(ApiGateway::class);

        $api->none();
    }

指定したメソッドを呼び出しても何も起こらないのですが、呼び出しに失敗することもありません。

複数のメソッドを指定してそれぞれに返却値を設定するには?

上で書いた createConfiguredStub() で複数のメソッドの返却値を生成時に一度に設定できるのですが、スタブを生成した後から設定するにはどう書くと良いのか気になります。

インタフェース定義に string を返す適当なメソッドを追加して、

<?php
...
    public function unknown(): string;

それぞれのメソッドの返却値を method() と willReturn() で指定して、直接呼んでみます。

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

        $api->method('invoke')
            ->willReturn('220');

        $api->method('unknown')
            ->willReturn('known');

        $this->assertSame($api->invoke('name', 'apple'), '220');

        $this->assertSame($api->unknown(), 'known');
    }

それぞれがいい感じに設定できています。

まとめ

PHPUnit のスタブについてサンプルコードを書きながらまとめてみました。
結果としてペアで書いていたテストコードには、その場では一番しっくりきた willReturnCallback() を採用しましたが、場面に応じてシンプルな書き方ができるように複数の方法が用意されていることがよく理解できました。

高機能な専用のモックライブラリがたくさんある中で PHPUnit のスタブを使う機会はそう多くないかもしれないのですが、標準で用意されているため、さっと気軽にテストを書ける点がとてもいいなって思います。

また、簡単ながらも実際にコードを書いて動きをみると、こういうときはどう書くんだろう、とか、こういう書き方の方がわかりやすいかも、とあれこれ考えることができて楽しいです。

機会があれば、モックオブジェクトやモックビルダーについてもまとめてみたいと思います。

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

参考

  • https://docs.phpunit.de/en/10.5/test-doubles.html

    • PHPUnit 10.5 Manual 6. Test Doubles

    • 公式のマニュアルはとても読みやすいのですが、サンプルコードが一部わかりにくいと感じるものもあり、それもあって自分で書いてみると理解が深まる気がします

  • https://github.com/sebastianbergmann/phpunit/

    • PHPUnit のコードリポジトリ

    • どんな構成になっているか、マニュアルに書かれていない公開機能がないか、など気になると直接コードを見ることができるのは、オープンソースのとてもいいところだなって思います