Mockeryでモックを使ったユニットテストを書くハンズオン

概要

PHP のユニットテストでは、テスト対象が依存する外部コンポーネント(DB アクセス、外部 API、メール送信など)を「本物」のまま動かしたくない場面が頻繁にあります。こうした依存をニセモノ(モック)に差し替え、「どう呼ばれるべきか」「何を返すか」をテストコード側で制御するためのライブラリが Mockery です。

PHPUnit 標準のモック機能もありますが、Mockery は流れるような記述、スパイ、部分モックなどが書きやすく、Laravel でも広く使われています。本記事ではインストールから各機能までをハンズオン形式でまとめます。

環境

  • PHP 8.2 / PHPUnit 10.x / Mockery 1.6.x / Composer 2.x

インストール

開発用依存として追加します。

composer require --dev mockery/mockery phpunit/phpunit

題材となるコード

リポジトリ(永続化)とメール通知に依存するユーザー登録サービスをテストします。

<?php
// src/User.php
namespace App;

final class User
{
    public function __construct(
        public readonly int $id,
        public readonly string $email,
    ) {}
}
<?php
// src/UserRepository.php
namespace App;

interface UserRepository
{
    public function existsByEmail(string $email): bool;
    public function save(string $email): User;
}
<?php
// src/Mailer.php
namespace App;

interface Mailer
{
    public function sendWelcome(User $user): void;
}
<?php
// src/RegisterUserService.php
namespace App;

use RuntimeException;

final class RegisterUserService
{
    public function __construct(
        private readonly UserRepository $repository,
        private readonly Mailer $mailer,
    ) {}

    public function register(string $email): User
    {
        if ($this->repository->existsByEmail($email)) {
            throw new RuntimeException('email already registered');
        }
        $user = $this->repository->save($email);
        $this->mailer->sendWelcome($user);
        return $user;
    }
}

ハンズオン1: 戻り値を差し替える

新規メールの場合に正常登録できることを確認します。

<?php
// tests/RegisterUserServiceTest.php
namespace Tests;

use App\Mailer;
use App\RegisterUserService;
use App\User;
use App\UserRepository;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;

final class RegisterUserServiceTest extends TestCase
{
    use MockeryPHPUnitIntegration;

    public function test_新規メールなら登録できる(): void
    {
        $repository = Mockery::mock(UserRepository::class);
        $repository->shouldReceive('existsByEmail')
            ->once()->with('alice@example.com')->andReturn(false);
        $repository->shouldReceive('save')
            ->once()->with('alice@example.com')
            ->andReturn(new User(1, 'alice@example.com'));

        $mailer = Mockery::mock(Mailer::class);
        $mailer->shouldReceive('sendWelcome')->once();

        $service = new RegisterUserService($repository, $mailer);
        $user = $service->register('alice@example.com');

        $this->assertSame(1, $user->id);
        $this->assertSame('alice@example.com', $user->email);
    }
}

基本形は Mockery::mock(Interface::class) → shouldReceive('method') → with() / andReturn() / once() です。

ハンズオン2: 例外を検証する

既に登録済みのメールなら例外を投げ、保存もメール送信も呼ばれないことを検証します。

public function test_登録済みメールは例外になる(): void
{
    $repository = Mockery::mock(UserRepository::class);
    $repository->shouldReceive('existsByEmail')
        ->once()->with('bob@example.com')->andReturn(true);
    $repository->shouldNotReceive('save');

    $mailer = Mockery::mock(Mailer::class);
    $mailer->shouldNotReceive('sendWelcome');

    $service = new RegisterUserService($repository, $mailer);

    $this->expectException(\RuntimeException::class);
    $this->expectExceptionMessage('email already registered');
    $service->register('bob@example.com');
}

shouldNotReceive() により「呼ばれてはいけない」ことまで検証できるのがモックの大きな価値です。

ハンズオン3: 引数マッチャ

use Mockery;

$mailer->shouldReceive('sendWelcome')->with(Mockery::type(User::class));

$mailer->shouldReceive('sendWelcome')->with(Mockery::on(
    fn (User $user) => str_contains($user->email, '@example.com')
));
マッチャ 意味
Mockery::any() 任意の値
Mockery::type('string') 型で判定
Mockery::on($closure) クロージャが true で一致
Mockery::pattern('/^\d+$/') 正規表現

ハンズオン4: 戻り値を複数・連続で返す

$repository->shouldReceive('existsByEmail')->andReturn(false, true);
$repository->shouldReceive('save')
    ->andReturnUsing(fn (string $email) => new User(99, $email));
$repository->shouldReceive('save')
    ->andThrow(new \RuntimeException('db error'));

ハンズオン5: 部分モック

実クラスの一部メソッドだけ差し替えます。

$service = Mockery::mock(ReportService::class)->makePartial();
$service->shouldReceive('heavyCalculation')->andReturn(1);
// 明示していないメソッドは本物の実装が動く

ハンズオン6: スパイ

事前に期待を宣言せず、実行後に検証します。

public function test_スパイで呼び出しを後から検証する(): void
{
    $repository = Mockery::mock(UserRepository::class);
    $repository->shouldReceive('existsByEmail')->andReturn(false);
    $repository->shouldReceive('save')
        ->andReturn(new User(1, 'carol@example.com'));

    $mailer = Mockery::spy(Mailer::class);

    (new RegisterUserService($repository, $mailer))
        ->register('carol@example.com');

    $mailer->shouldHaveReceived('sendWelcome')
        ->once()->with(Mockery::type(User::class));
}

確認方法

./vendor/bin/phpunit tests/

本番コードの sendWelcome() 呼び出しをわざと削り、once() やスパイのテストが失敗することも確認しておくと、テストが機能している証拠になります。

注意点

Mockery::close() を必ず呼ぶ

Mockery は once() 等の回数検証をテスト終了時に行うため、Mockery::close() が呼ばれないと検証が走りません。MockeryPHPUnitIntegration トレイトを use すれば自動で呼ばれます。使わない場合は自前の tearDown() で明示的に呼びます。

protected function tearDown(): void
{
    Mockery::close();
    parent::tearDown();
}

インターフェースに対してモックする

具象クラスに密結合したモックは壊れやすくなります。可能な限りインターフェースに対してモックし、本番コードも DI でインターフェースを受け取る設計にしておくと安定します。

モックしすぎない

依存を全部モックすると「実装を写経しただけ」のテストになり、リファクタに弱くなります。純粋ロジックは本物を使い、外部 I/O の境界だけモックするのが現実的です。

まとめ

  • 導入は composer require --dev mockery/mockery
  • Mockery::mock(Interface::class) + shouldReceive() が基本形
  • with() / andReturn() / once() / shouldNotReceive() で呼ばれ方を制御・検証
  • 部分モックは makePartial()、スパイは spy() + shouldHaveReceived()
  • Mockery::close()(=MockeryPHPUnitIntegration トレイト)を忘れないことが最重要

外部 I/O を境界でモックすることで、速く・決定的に動くユニットテストが書けます。インターフェース依存なクラス 1 つから試してみてください。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。