【PHPテスト入門 第5回】テストダブル:スタブ・モック・フェイクの使い分け

PHP
B!

はじめに

今回のゴールは、PHPUnitの機能を使って、テスト用の「偽物」をその場で作れるようになることです。

前回は依存性注入(DI)を使い、現在時刻や在庫APIを外から渡せる形にしました。テストでは FixedClock や InMemoryInventoryApi といったテスト用のクラスを手書きして渡しました。この方法はわかりやすい反面、依存するものが増えるたびにクラスを作る必要があります。

この回を読み終えると、次のことができるようになります。

  • スタブ・モック・フェイクの違いを説明できる
  • createStub() で、決まった値や例外を返す偽物を作れる
  • createMock() で、「メソッドが正しく呼ばれたか」を検証できる
  • スタブとモックを使い分け、モックの使いすぎを避けられる

題材は、前回紹介した在庫チェックの StockChecker と、新しく加える「注文受付サービス」です。

動作確認環境はこれまでと同じ、PHP 8.4.26、Composer 2.10.3、PHPUnit 13.3.5 です。記事中の実行結果は、すべてこの環境で実際に動かして取得したものです。

テストダブルとは

テストのために、本物の代わりに使う偽物の部品をまとめてテストダブルと呼びます。映画の危険なシーンで俳優の代わりを務める「スタントダブル(代役)」が語源です。

テストダブルには役割によっていくつかの種類があります。この連載でよく使うのは、次の3つです。

種類役割たとえるとこの連載での例
スタブ決まった値を返す(または例外を投げる)だけ台本どおりに答えるだけの代役「在庫は3個です」と答える在庫API
モック呼ばれ方(回数・引数)を記録し、期待どおりかを検証する台本どおりに演じたかをチェックする監督つきの代役「確認メールが1回送られたか」を確かめる通知
フェイク本物より単純だが、実際に動く実装本物そっくりに動く簡易版第4回の FixedClock・InMemoryInventoryApi

このほかにも、引数を埋めるためだけに渡す「ダミー」や、呼ばれ方を記録するだけの「スパイ」といった分類があります。ただ、実務で意識して使い分けるのは、上の3つで十分です。

スタブとモックの違いは「何を確かめるか」

一番混同しやすいのが、スタブとモックです。見分けるポイントは、そのテストで何を確かめたいかです。

  • スタブ:テスト対象に「入力」を与えるために使う。確かめるのは、テスト対象が返した結果
  • モック:テスト対象が外に向けて「何をしたか」を確かめるために使う。確かめるのは、テスト対象の行動

在庫チェックなら、確かめたいのは「在庫が3個のとき、canOrder() が true を返すか」という結果です。在庫APIは入力を与える役なので、スタブが向いています。

一方、「注文が成功したら確認メールを送る」処理では、メールを送ったこと自体が確かめたい行動です。メールの送信は戻り値に表れないので、モックで「send() が呼ばれたか」を検証します。

スタブ:createStub() で決まった値を返す

準備:在庫チェックのクラス

前回紹介した StockChecker を、実際のファイルとして作ります。今回は、在庫APIとの通信に失敗したときの動きも仕様に加えました。

  • 在庫数が注文数以上なら注文できる
  • 在庫APIとの通信に失敗したら、売り越しを防ぐため注文を受け付けない

src/Stock/InventoryApi.php

<?php

declare(strict_types=1);

namespace App\Stock;

interface InventoryApi
{
    /**
     * @throws InventoryApiException 在庫APIとの通信に失敗したとき
     */
    public function fetchStock(string $sku): int;
}

src/Stock/InventoryApiException.php

<?php

declare(strict_types=1);

namespace App\Stock;

use RuntimeException;

final class InventoryApiException extends RuntimeException
{
}

src/Stock/StockChecker.php

<?php

declare(strict_types=1);

namespace App\Stock;

final class StockChecker
{
    public function __construct(
        private readonly InventoryApi $inventory,
    ) {
    }

    public function canOrder(string $sku, int $quantity): bool
    {
        try {
            return $this->inventory->fetchStock($sku) >= $quantity;
        } catch (InventoryApiException) {
            // 在庫が確認できないときは、売り越しを防ぐため注文を受け付けない
            return false;
        }
    }
}

catch (InventoryApiException) のように、例外を受け取る変数を省略する書き方はPHP 8.0から使えます。例外の中身を使わないときに便利です。

基本:willReturn() で値を返す

createStub() にインターフェース名を渡すと、そのインターフェースを実装した偽物がその場で作られます。method() でメソッドを指定し、willReturn() で返す値を決めます。

tests/Stock/StockCheckerTest.php

<?php

declare(strict_types=1);

namespace Tests\Stock;

use App\Stock\InventoryApi;
use App\Stock\StockChecker;
use PHPUnit\Framework\TestCase;

final class StockCheckerTest extends TestCase
{
    public function test_在庫が注文数以上なら注文できる(): void
    {
        $inventory = $this->createStub(InventoryApi::class);
        $inventory->method('fetchStock')->willReturn(3);

        $checker = new StockChecker($inventory);

        $this->assertTrue($checker->canOrder('APPLE-001', 3));
    }

    public function test_在庫が注文数より少なければ注文できない(): void
    {
        $inventory = $this->createStub(InventoryApi::class);
        $inventory->method('fetchStock')->willReturn(2);

        $checker = new StockChecker($inventory);

        $this->assertFalse($checker->canOrder('APPLE-001', 3));
    }
}

第4回では、同じことをするために InMemoryInventoryApi クラスを手書きしました。スタブなら、テストの中の2行で済みます。

引数ごとに違う値を返す:willReturnMap()

商品によって在庫数を変えたいときは、willReturnMap() を使います。「引数」と「返す値」の組み合わせを配列で並べます。

public function test_商品ごとに違う在庫数を返す(): void
{
    $inventory = $this->createStub(InventoryApi::class);
    $inventory->method('fetchStock')->willReturnMap([
        ['APPLE-001', 10],
        ['ORANGE-001', 0],
    ]);

    $checker = new StockChecker($inventory);

    $this->assertTrue($checker->canOrder('APPLE-001', 5));
    $this->assertFalse($checker->canOrder('ORANGE-001', 1));
}

内側の配列は「引数を順に並べ、最後に返す値」という形です。['APPLE-001', 10] なら、「fetchStock('APPLE-001') と呼ばれたら 10 を返す」という意味になります。

例外を投げさせる:willThrowException()

スタブの本領が発揮されるのが、本物では再現しにくい状況を作るときです。在庫APIのタイムアウトを本物で起こすのは大変ですが、スタブなら1行です。

use App\Stock\InventoryApiException;

// ...

public function test_在庫の問い合わせに失敗したら注文できない(): void
{
    $inventory = $this->createStub(InventoryApi::class);
    $inventory->method('fetchStock')
        ->willThrowException(new InventoryApiException('接続がタイムアウトしました'));

    $checker = new StockChecker($inventory);

    $this->assertFalse($checker->canOrder('APPLE-001', 1));
}
....                                                                4 / 4 (100%)

OK (4 tests, 5 assertions)

通信エラーのような「めったに起きないが、起きたときに正しく動かないと困る」処理こそ、テストで確かめておく価値があります。

テスト名を最初は test_在庫APIが失敗したら注文できない にしていましたが、--testdox で表示すると「在庫 a p iが失敗したら」と崩れてしまいました。テスト名に英字の略語を入れるときは、#[TestDox] 属性で表示名を指定するか、名前を言い換えるのがおすすめです。

モック:createMock() で「呼ばれ方」を検証する

準備:注文受付サービス

在庫を確認して注文を受け付け、成功したらお客様に確認メールを送るサービスを作ります。メール送信は Notifier インターフェースとして切り出しておきます。本番ではメール送信の実装を、テストでは偽物を渡すためです。

src/Order/Notifier.php

<?php

declare(strict_types=1);

namespace App\Order;

interface Notifier
{
    public function send(string $to, string $message): void;
}

src/Order/OrderService.php

<?php

declare(strict_types=1);

namespace App\Order;

use App\Stock\StockChecker;

final class OrderService
{
    public function __construct(
        private readonly StockChecker $stockChecker,
        private readonly Notifier $notifier,
    ) {
    }

    public function placeOrder(string $email, string $sku, int $quantity): bool
    {
        if (!$this->stockChecker->canOrder($sku, $quantity)) {
            return false;
        }

        $this->notifier->send($email, "ご注文を受け付けました({$sku} × {$quantity})");

        return true;
    }
}

send() の戻り値は void です。つまり、placeOrder() の戻り値をいくら調べても、「メールが送られたかどうか」はわかりません。ここでモックの出番です。

expects() で呼ばれ方を指定する

tests/Order/OrderServiceTest.php

<?php

declare(strict_types=1);

namespace Tests\Order;

use App\Order\Notifier;
use App\Order\OrderService;
use App\Stock\InventoryApi;
use App\Stock\StockChecker;
use PHPUnit\Framework\TestCase;

final class OrderServiceTest extends TestCase
{
    private function stockCheckerWithStock(int $stock): StockChecker
    {
        $inventory = $this->createStub(InventoryApi::class);
        $inventory->method('fetchStock')->willReturn($stock);

        return new StockChecker($inventory);
    }

    public function test_注文が成功したら確認メールを1回送る(): void
    {
        $notifier = $this->createMock(Notifier::class);
        $notifier->expects($this->once())
            ->method('send')
            ->with('taro@example.com', 'ご注文を受け付けました(APPLE-001 × 2)');

        $service = new OrderService($this->stockCheckerWithStock(10), $notifier);

        $this->assertTrue($service->placeOrder('taro@example.com', 'APPLE-001', 2));
    }

    public function test_在庫不足なら確認メールを送らない(): void
    {
        $notifier = $this->createMock(Notifier::class);
        $notifier->expects($this->never())->method('send');

        $service = new OrderService($this->stockCheckerWithStock(1), $notifier);

        $this->assertFalse($service->placeOrder('taro@example.com', 'APPLE-001', 2));
    }
}

モックの設定は、次のように読みます。

書き方意味
expects($this->once())ちょうど1回呼ばれることを期待する
expects($this->never())1回も呼ばれないことを期待する
expects($this->exactly(3))ちょうど3回呼ばれることを期待する
->method('send')対象は send() メソッド
->with(引数1, 引数2)この引数で呼ばれることを期待する

1つ目のテストでは、スタブとモックを両方使っています。在庫APIは「在庫10個」という入力を与えるだけなのでスタブ、通知は「送ったか」を確かめたいのでモックです。まさに前の章で説明した使い分けです。

モックの検証は、テストメソッドの最後に自動で行われます。自分で assert〇〇() を書く必要はありません。

..                                                                  2 / 2 (100%)

OK (2 tests, 6 assertions)

モックが見つけてくれるバグ

モックの効果を確かめるため、OrderService にわざとバグを入れてみます。まずは、メール送信の行をうっかり消してしまった場合です。

F.                                                                  2 / 2 (100%)

There was 1 failure:

1) Tests\Order\OrderServiceTest::test_注文が成功したら確認メールを1回送る
App\Order\Notifier::send() was expected to be invoked once but was never invoked.

FAILURES!
Tests: 2, Assertions: 4, Failures: 1.

「send() は1回呼ばれるはずだったが、一度も呼ばれなかった」と教えてくれます。placeOrder() は true を返しているので、戻り値だけを見るテストでは、このバグに気づけません。

次は、メールの文面を変えてしまった場合です。

1) Tests\Order\OrderServiceTest::test_注文が成功したら確認メールを1回送る
Expectation for App\Order\Notifier::send() failed.
Parameter $message for invocation App\Order\Notifier::send('taro@example.com', 'ご注文ありがとうございます(APPLE-001 × 2)'): void does not match expected value.
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'ご注文を受け付けました(APPLE-001 × 2)'
+'ご注文ありがとうございます(APPLE-001 × 2)'

- の行が期待した値、+ の行が実際の値です。どの引数が、どう違ったのかが一目でわかります。

【スクショ:モックの検証に失敗し、Expected と Actual の差分が表示された実行結果】

使い分けと、つまずきやすいポイント

迷ったらスタブ、行動を確かめたいときだけモック

基本方針はシンプルです。まずスタブで書けないかを考え、「外に向けた行動」を確かめる必要があるときだけモックを使う。

モックを使いすぎると、テストが実装の細部に縛られてしまいます。たとえば、在庫APIをモックにして「fetchStock() がちょうど1回呼ばれること」まで検証していたとしましょう。

// やりすぎの例:在庫APIの呼ばれ方まで固定してしまっている
$inventory = $this->createMock(InventoryApi::class);
$inventory->expects($this->once())
    ->method('fetchStock')
    ->with('APPLE-001')
    ->willReturn(10);

後から性能改善のために在庫数をキャッシュする変更を入れると、fetchStock() の呼ばれる回数が変わり、このテストは失敗します。注文の受付という振る舞いは何も変わっていないのに、テストが壊れるわけです。

こうしたテストが増えると、リファクタリングのたびに大量のテストを直すことになります。第1回でお話しした「コードを直すのが怖くなくなる」という効果とは正反対です。「呼ばれ方」を検証するのは、メール送信や決済のように、それ自体がそのコードの目的である行動に限りましょう。

ここからは、テストダブルでつまずきやすい3つのポイントを見ていきます。実行結果は、検証用の別ファイル DoubleTrapsTest.php で取得したものです。

つまずき1:スタブに expects() は使えない

createStub() で作ったスタブに expects() を書くと、エラーになります。

$notifier = $this->createStub(Notifier::class);
$notifier->expects($this->once())->method('send');
1) DoubleTrapsTest::test_スタブにexpectsは使えない
Error: Call to undefined method TestStub_Notifier_8276512e::expects()

呼ばれ方を検証したいなら、createMock() を使います。スタブは「値を返すだけ」、モックは「呼ばれ方を検証する」と、PHPUnit自体が役割をはっきり分けているのです。

古い記事では、スタブにも expects() を書いている例を見かけます。これはPHPUnit 11で非推奨になり、PHPUnit 12で使えなくなった書き方です。

つまずき2:期待値のないモックには注意が出る

逆に、createMock() で作ったのに expects() を1つも書かないと、テストは通るものの、PHPUnitから注意(Notice)が出ます。

N                                                                   1 / 1 (100%)

OK, but there were issues!
Tests: 1, Assertions: 1, PHPUnit Notices: 1.

N の中身は、--display-phpunit-notices オプションを付けて実行すると確認できます。

1) DoubleTrapsTest::test_期待値を設定しないモック
No expectations were configured for the mock object for App\Stock\InventoryApi. Consider refactoring your test code to use a test stub instead. The #[AllowMockObjectsWithoutExpectations] attribute can be used to opt out of this check.

「モックに期待値が設定されていません。スタブを使うよう書き直すことを検討してください」という意味です。値を返すだけなら createStub() にしましょう。

つまずき3:final クラスは偽物にできない

この連載のクラスには、継承を禁止する final を付けています。PHPUnitのテストダブルは、元のクラスを継承して作られるため、final クラスは偽物にできません。

$checker = $this->createStub(StockChecker::class);
1) DoubleTrapsTest::test_finalクラスは偽物にできない
PHPUnit\Framework\MockObject\Generator\ClassIsFinalException: Class "App\Stock\StockChecker" is declared "final" and cannot be doubled

OrderServiceTest で、StockChecker そのものではなく在庫APIの方をスタブにして、本物の StockChecker に渡したのはこのためです。

偽物にしたいものは、第4回で学んだようにインターフェースとして切り出しておくのが基本です。「テストで差し替えたいのに差し替えられない」と感じたら、それはインターフェースを切り出すべき場所だというサインです。

スタブ・モック・フェイクの選び方

こんなとき使うもの
決まった値や例外を返してほしいだけスタブ(createStub())
メール送信・決済など、外への行動が起きたかを確かめたいモック(createMock() + expects())
多くのテストで使い回す、状態を持った偽物が欲しいフェイク(自作クラス)

たとえば FixedClock は、ほぼすべての時刻関連のテストで使う部品です。毎回スタブを組み立てるより、フェイクとして一度作っておいたほうが、テストが短く読みやすくなります。

PHPには、PHPUnit標準の機能とは別に「Mockery」というテストダブル用の人気ライブラリもあります。Laravelのプロジェクトでは最初から入っていることが多いので、第8回で簡単に紹介します。

今回の完成コード

今回追加したファイルは次のとおりです。すべて本文に掲載したコードが完成形です。

php-test-cart/
├── src/
│   ├── Cart/ …
│   ├── Clock/ …
│   ├── Order/
│   │   ├── Notifier.php               ← 追加
│   │   └── OrderService.php           ← 追加
│   ├── Sale/ …
│   └── Stock/
│       ├── InventoryApi.php           ← 追加
│       ├── InventoryApiException.php  ← 追加
│       └── StockChecker.php           ← 追加
└── tests/
    ├── Cart/ …
    ├── Order/
    │   └── OrderServiceTest.php       ← 追加
    ├── Sale/ …
    ├── Stock/
    │   └── StockCheckerTest.php       ← 追加
    └── Support/ …

StockCheckerTest.php は本文で分けて紹介したので、use 文を含む冒頭部分だけ補足しておきます。

<?php

declare(strict_types=1);

namespace Tests\Stock;

use App\Stock\InventoryApi;
use App\Stock\InventoryApiException;
use App\Stock\StockChecker;
use PHPUnit\Framework\TestCase;

final class StockCheckerTest extends TestCase
{
    // 本文の4つのテストメソッド
}

プロジェクト全体のテストを実行すると、これまでの分と合わせて21件がすべて通ります。

.....................                                             21 / 21 (100%)

OK (21 tests, 29 assertions)

まとめ

  • テストで本物の代わりに使う偽物を、まとめてテストダブルと呼ぶ
  • スタブは「値を返すだけ」で、テスト対象の結果を確かめるときに使う
  • モックは「呼ばれ方を検証する」もので、メール送信のような外への行動を確かめるときに使う
  • 迷ったらスタブ。モックの使いすぎは、実装の細部に縛られた壊れやすいテストを生む
  • final クラスは偽物にできない。差し替えたいものはインターフェースとして切り出す

次回予告

第6回は「DBを使うテスト」です。ここまでは、DBのような外部の仕組みをテストダブルで置き換えてきました。しかし、SQLが正しいか、データが本当に保存されるかは、実際にDBを動かさないと確かめられません。SQLiteを使ってテスト用のDBを用意し、テストごとにデータを準備して後片付けする方法を扱います。第3回で紹介した tearDown() も、いよいよ登場します。

参考資料

PHPテスト入門(全10回)
  1. 第1回:なぜテストを書くのか
  2. 第2回:PHPUnitの環境構築と最初のテスト
  3. 第3回:読みやすいテストの書き方
  4. 第4回:テストしやすいコードの書き方
  5. 第5回:テストダブル(今回)
  6. 第6回:DBを使うテスト (10/3 公開予定)
  7. 第7回:Laravelのテスト基盤 (10/4 公開予定)
  8. 第8回:Laravelのフェイク機能とPest (10/5 公開予定)
  9. 第9回:カバレッジとCI (10/6 公開予定)
  10. 第10回:TDDとレガシーコード (10/7 公開予定)
← 前の回:第4回 テストしやすいコードの書き方次の回:第6回 DBを使うテスト(10/3 公開予定)
B!
← 一覧へ戻る