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 つから試してみてください。
