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
詰まったとき:
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
);
}
}
ここでは DisposableEmailChecker と WelcomeMailer をインターフェースにしています。
外部依存を直接モックしようとして 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 found | docker compose exec app composer dump-autoload | オートロード未更新、名前空間ミス | composer dump-autoload 後に namespace と use を見直す |
ベンダーの 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) も参照してください。