公開日 2026-05-24

PHPUnitでテストダブル入門(モック / スタブ最小構成)

外部APIとメール送信を PHPUnit のスタブ / モックで置き換え、何を本物のまま残すべきかを最小例で判断できるようにする。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. 新規デモ環境を作成して起動する
  4. 3. PHPUnitと最小アプリコードを用意する
  5. 4. 失敗するサービスとテストを作る
  6. コードのポイント
  7. 5. スタブで外部APIの戻り値を固定する
  8. 6. モックでメール送信を検証して修正する
  9. コードのポイント
  10. 7. 何を本物のままにして、何を差し替えるか
  11. 8. よくある詰まりと次の一歩

PHPUnit の最初の 1 本は通したものの、外部 API やメール送信を含むコードをどう切り分けてテストすればよいかまだ整理できていない読者向けです。
この 1 本で、捨てメール判定 API をスタブに置き換え、ウェルカムメール送信をモックで検証する最小例を作ります。

補助導線として先に触っておくと入りやすい記事:

既存記事を未読でも、そのまま進められる構成です。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL 連携有効)

以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
サービス名は app 固定です。

1. ゴールと非対象

到達する状態:

  • createStub()createMock() の役割を分けて説明できる
  • 外部 API とメール送信を切り離した最小テストを作れる
  • 何を本物のまま残し、何を差し替えるか判断できる

扱わない内容:

  • Laravel / Symfony のテストヘルパー
  • 実際の HTTP 通信や SMTP 設定
  • DB 統合テスト
  • GitHub Actions での自動実行

ここで集中するのは、サービスクラスの振る舞いを外部依存から切り離して確かめることです。
SQL やトランザクションの正しさは DB テストの担当で、7 章で判断基準に触れます。

2. 新規デモ環境を作成して起動する

記事用の最小デモから始めます。

mkdir -p ~/projects/phpunit-test-doubles-intro-demo
cd ~/projects/phpunit-test-doubles-intro-demo
mkdir -p docker/php src tests
code .

最初に置くのは compose.yml です。

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    working_dir: /workspace
    volumes:
      - ./:/workspace
    command: ["sleep", "infinity"]

docker/php/Dockerfile を作成し、PHPUnit 13 に必要な mbstring も合わせて追加しておきます。

FROM php:8.5-cli

RUN apt-get update \
    && apt-get install -y --no-install-recommends git unzip libonig-dev \
    && docker-php-ext-install mbstring \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /workspace

composer.json の内容は次のとおりです。

{
  "name": "example/phpunit-test-doubles-intro-demo",
  "type": "project",
  "require": {},
  "require-dev": {},
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  }
}

起動して確認します。

docker compose up -d --build
docker compose exec app php -v
docker compose exec app composer --version
PHP・Composerのバージョン確認

詰まったとき:

docker compose logs app

Dockerfile を直したあとに結果が変わらない場合は、docker compose up -d --build を再実行してください。

3. PHPUnitと最小アプリコードを用意する

ここから PHPUnit 導入です。
この記事では現行 stable の phpunit/phpunit:^13 を使い、Docker 内の再現手順をそのまま試せる構成にそろえます。

docker compose exec app composer require --dev phpunit/phpunit:^13
docker compose exec app vendor/bin/phpunit --version

phpunit.xml の内容は次のとおりです。

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
  <testsuites>
    <testsuite name="default">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
</phpunit>

次に、テストダブルを差し込むためのインターフェースと、本物のまま使うクラスを置きます。

src/DisposableEmailChecker.php:

<?php
declare(strict_types=1);

namespace App;

interface DisposableEmailChecker
{
    public function isDisposable(string $email): bool;
}

src/WelcomeMailer.php:

<?php
declare(strict_types=1);

namespace App;

interface WelcomeMailer
{
    public function sendWelcome(string $email, string $message): void;
}

src/WelcomeMessageBuilder.php:

<?php
declare(strict_types=1);

namespace App;

final class WelcomeMessageBuilder
{
    public function build(string $name): string
    {
        return sprintf(
            '%sさん、ようこそ。7日間のトライアルを始めましょう。',
            $name
        );
    }
}

ここでは DisposableEmailCheckerWelcomeMailer をインターフェースにしています。
外部依存を直接モックしようとして final なベンダークラスで詰まるより、この段階で境界を 1 枚置くほうが扱いやすくなります。

詰まったとき:

docker compose exec app composer dump-autoload
docker compose exec app ls -la vendor/bin

4. 失敗するサービスとテストを作る

ここでは、あえて順序の悪い実装を置きます。
捨てメールかどうかを調べる前にウェルカムメールを送ってしまう版です。

src/TrialSignupService.php を作成します(失敗版)。

<?php
declare(strict_types=1);

namespace App;

final class TrialSignupService
{
    public function __construct(
        private DisposableEmailChecker $checker,
        private WelcomeMailer $mailer,
        private WelcomeMessageBuilder $messageBuilder,
    ) {
    }

    public function register(string $name, string $email): bool
    {
        $message = $this->messageBuilder->build($name);

        $this->mailer->sendWelcome($email, $message);

        return !$this->checker->isDisposable($email);
    }
}

tests/TrialSignupServiceTest.php は次の内容です。

<?php
declare(strict_types=1);

namespace Tests;

use App\DisposableEmailChecker;
use App\TrialSignupService;
use App\WelcomeMailer;
use App\WelcomeMessageBuilder;
use PHPUnit\Framework\TestCase;

final class TrialSignupServiceTest extends TestCase
{
    public function testRegisterSkipsWelcomeMailForDisposableEmail(): void
    {
        $checker = $this->createStub(DisposableEmailChecker::class);
        $checker->method('isDisposable')->willReturn(true);

        $mailer = $this->createMock(WelcomeMailer::class);
        $mailer->expects($this->never())->method('sendWelcome');

        $service = new TrialSignupService(
            $checker,
            $mailer,
            new WelcomeMessageBuilder()
        );

        $result = $service->register('Taro', 'temp@example.com');

        $this->assertFalse($result);
    }

    public function testRegisterSendsWelcomeMailForNormalEmail(): void
    {
        $checker = $this->createStub(DisposableEmailChecker::class);
        $checker->method('isDisposable')->willReturn(false);

        $mailer = $this->createMock(WelcomeMailer::class);
        $mailer->expects($this->once())
            ->method('sendWelcome')
            ->with(
                'taro@example.com',
                'Taroさん、ようこそ。7日間のトライアルを始めましょう。'
            );

        $service = new TrialSignupService(
            $checker,
            $mailer,
            new WelcomeMessageBuilder()
        );

        $result = $service->register('Taro', 'taro@example.com');

        $this->assertTrue($result);
    }
}

ここでは 1 つのテストファイルに createStub()createMock() の両方が出てきます。
次の 2 章で、それぞれの役割を分けて確認します。

コードのポイント

① 失敗版の処理順(TrialSignupService.php

    public function register(string $name, string $email): bool
    {
        $message = $this->messageBuilder->build($name);

        $this->mailer->sendWelcome($email, $message);

        return !$this->checker->isDisposable($email);
    }

メール送信(行5)が捨てメール判定(行7)より先に実行されている。捨てメールアドレスでも sendWelcome() が呼ばれてしまうため、次章のテストが失敗する。

createStub() で戻り値を固定する

$checker = $this->createStub(DisposableEmailChecker::class);
$checker->method('isDisposable')->willReturn(true);

createStub() は戻り値を固定するだけで、呼び出し回数は検証しない。ここでは API が true を返した場合の分岐だけを再現し、実際の HTTP 通信を発生させない。

createMock() で呼び出しを検証する

$mailer = $this->createMock(WelcomeMailer::class);
$mailer->expects($this->never())->method('sendWelcome');

createMock() は呼び出し回数まで検証する。expects($this->never()) は「1 回も呼ばれてはいけない」という制約で、捨てメールのケースでメール送信が起きないことを確かめる。

expects($this->once())with() で引数まで検証する

$mailer->expects($this->once())
    ->method('sendWelcome')
    ->with(
        'taro@example.com',
        'Taroさん、ようこそ。7日間のトライアルを始めましょう。'
    );

expects($this->once()) は「1 回だけ呼ばれる」制約、with() はどの引数で呼ばれたかを検証する。送信したかどうかだけでなく、送先アドレスとメッセージ本文が正しいかまで確認できる。

5. スタブで外部APIの戻り値を固定する

先に失敗を出しておきます。

docker compose exec app vendor/bin/phpunit tests/TrialSignupServiceTest.php
テスト失敗時の出力

失敗出力の要点:

  • testRegisterSkipsWelcomeMailForDisposableEmail() が落ちる
  • sendWelcome(...): void was not expected to be called. と出る
  • 原因は TrialSignupService が捨てメール判定より先にメール送信していること

ここでのスタブは DisposableEmailChecker で、$checker->method('isDisposable')->willReturn(true); とすることで実際の API 通信は発生しません。 それでも「捨てメールだった場合の分岐」だけは再現できます。

スタブの役割を短く言うと、戻り値を固定してサービスを狙った条件へ進めることです。
このテストで知りたいのは API の精度ではなく、API が true を返したときにサービスがどう振る舞うかだからです。

6. モックでメール送信を検証して修正する

src/TrialSignupService.php を修正版に更新します。

<?php
declare(strict_types=1);

namespace App;

final class TrialSignupService
{
    public function __construct(
        private DisposableEmailChecker $checker,
        private WelcomeMailer $mailer,
        private WelcomeMessageBuilder $messageBuilder,
    ) {
    }

    public function register(string $name, string $email): bool
    {
        if ($this->checker->isDisposable($email)) {
            return false;
        }

        $message = $this->messageBuilder->build($name);

        $this->mailer->sendWelcome($email, $message);

        return true;
    }
}

再実行します。

docker compose exec app vendor/bin/phpunit tests/TrialSignupServiceTest.php
テスト成功時の出力

成功判定:

  • OK が表示される
  • 2 tests, 6 assertions が表示される

WelcomeMailer をモック対象とし、expects($this->never()) で「呼ばれてはいけない」、expects($this->once()) で「1 回だけ呼ばれる」を検証しています。 さらに with(...) によって、どのメールアドレスへ、どの本文で送ろうとしたかまで確認できます。

一方で WelcomeMessageBuilder は本物のままです。
このクラスは文字列を組み立てるだけで、ネットワークもファイルも触りません。
ここまでモックすると、読む側が「何をテストしているのか」を追いにくくなります。

コードのポイント

① 捨てメール判定を先に行うガード節(TrialSignupService.php

        if ($this->checker->isDisposable($email)) {
            return false;
        }

捨てメールと判定されたら即 return false し、それ以降のメール送信処理へ進まない。ガード節を先頭に置く修正で、失敗版の「送信後に判定」という順序の誤りが解消される。

7. 何を本物のままにして、何を差し替えるか

今回の題材での役割分担は次のとおりです。

対象ここでの扱い理由
DisposableEmailCheckerスタブネットワーク越しの結果を固定して、狙った分岐だけを再現したい
WelcomeMailerモック送信したか、していないか、どんな引数かを検証したい
WelcomeMessageBuilder本物文字列整形だけで副作用がなく、そのまま使うほうが読みやすい
TaskRepository の SQL やトランザクションDB テストモックでは SQL の誤りや状態変化を拾えない

過剰にモックしないための判断基準:

  • ネットワーク、メール送信、時刻、乱数、外部プロセスのように不安定な依存は差し替える
  • 純粋な整形ロジックや小さな値オブジェクトは本物のまま使う
  • SQL、トランザクション、制約、レコード状態の確認は DB テストへ回す
  • 依存ごとに期待回数を書き始めてテストが読みにくくなるなら、サービスの責務が大きすぎないか見直す

TaskRepository を全部モックしてしまうと、WHERE 句の抜けや状態リセット漏れは見えません。
そこは PHPUnitでDBテストを始める(PostgreSQL + Docker) のように、実 DB を使ったテストの担当です。

8. よくある詰まりと次の一歩

詰まりやすい点は次の 4 つです。

症状まず確認することよくある原因対処
vendor/bin/phpunit が見つからないdocker compose exec app composer show phpunit/phpunit依存導入前に実行しているcomposer require --dev phpunit/phpunit:^13 をやり直す
Class "App\\..." not founddocker compose exec app composer dump-autoloadオートロード未更新、名前空間ミスcomposer dump-autoload 後に namespaceuse を見直す
ベンダーの final クラスをそのままモックできない差し替え境界があるか外部ライブラリに直接依存している自前インターフェースを 1 枚かませる
テストダブルで安全そうに見えるのに本番で壊れる何をモックしたかSQL や永続化まで置き換えているDB テストへ分ける

切り分けに使う最小コマンド:

docker compose exec app vendor/bin/phpunit --version
docker compose exec app composer dump-autoload
docker compose exec app vendor/bin/phpunit tests/TrialSignupServiceTest.php

スタブは戻り値を固定して分岐を作るため、モックは副作用の呼び出しを検証するため、という役割の違いを確認しました。
SQL やレコード状態まで含めて確かめたい場合は、テストダブルのまま押し切らず、PHPUnitでDBテストを始める(PostgreSQL + Docker) も参照してください。

シリーズ 5/6

このシリーズ

PHPのテストと品質改善

  1. 1. PHPUnit入門(最初のテスト1本)
  2. 2. PHPStan入門(最初のエラー1件を直す)
  3. 3. GitHub ActionsでPHPUnit / PHP CS Fixer / PHPStanを回す最小CI
  4. 4. PHPUnitでDBテストを始める(PostgreSQL + Docker)
  5. 5. PHPUnitでテストダブル入門(モック / スタブ最小構成) 現在の記事
  6. 6. PHPStan レベルを上げる実践ガイド(レベル5→8 の壁を越える)