PHPUnitとMockeryで学ぶ依存分離とモックテストの実践
概要
「テストを書きたいが、DBや外部APIに依存していて書けない」——レガシー寄りのPHPコードで頻出する悩みです。本記事では、PHPUnitの標準モックとMockeryを使い分けながら、依存を切り離してユニットテストを書けるようにするまでをハンズオン形式で解説します。サンプルはすべて公開情報に基づく汎用例で、実在の案件情報は含みません。
環境
- PHP 8.2
- PHPUnit 10.x
- Mockery 1.6.x(
mockery/mockery)
composer require --dev phpunit/phpunit mockery/mockery
テストしづらいコードの典型例
<?php
class PointService
{
public function grant(int $userId, int $amount): void
{
$pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass'); // 直接生成
$stmt = $pdo->prepare('UPDATE users SET points = points + ? WHERE id = ?');
$stmt->execute([$amount, $userId]);
(new NotificationClient())->send($userId, "{$amount}ポイント付与しました"); // 直接生成
}
}
このコードは、テストのたびに実DBへ接続し、外部APIを本当に呼び出してしまいます。「付与時に通知が1回送られる」というロジックだけを取り出して検証できません。
原因
原因は、具体的な実装への依存を呼び出し側が内部で組み立てていることです。テスト時に差し替えたいもの(DB・外部API)がクラス内部に埋め込まれているため、テストから介入できません。解決の方向は依存性の逆転——具象ではなくインターフェースに依存させ、実体を外から注入します。
対応方法
1. 依存をインターフェースへ切り出す
<?php
interface PointRepository
{
public function addPoints(int $userId, int $amount): void;
}
interface Notifier
{
public function notify(int $userId, string $message): void;
}
2. インターフェースに依存させ、注入する
<?php
class PointService
{
public function __construct(
private PointRepository $repository,
private Notifier $notifier,
) {}
public function grant(int $userId, int $amount): void
{
if ($amount <= 0) {
throw new InvalidArgumentException('amount must be positive');
}
$this->repository->addPoints($userId, $amount);
$this->notifier->notify($userId, "{$amount}ポイント付与しました");
}
}
これで PointService は保存方法も通知方法も知らず、ドメインロジックだけを担う存在になりました。
実装例
PHPUnit標準モック
<?php
use PHPUnit\Framework\TestCase;
class PointServiceTest extends TestCase
{
public function test_保存と通知が1回ずつ呼ばれる(): void
{
$repository = $this->createMock(PointRepository::class);
$notifier = $this->createMock(Notifier::class);
$repository->expects($this->once())
->method('addPoints')->with(100, 50);
$notifier->expects($this->once())
->method('notify')->with(100, $this->stringContains('50ポイント'));
(new PointService($repository, $notifier))->grant(100, 50);
}
public function test_不正なamountで例外_副作用なし(): void
{
$repository = $this->createMock(PointRepository::class);
$notifier = $this->createMock(Notifier::class);
$repository->expects($this->never())->method('addPoints');
$notifier->expects($this->never())->method('notify');
$this->expectException(InvalidArgumentException::class);
(new PointService($repository, $notifier))->grant(100, 0);
}
}
副作用を持つメソッドは、戻り値ではなく expects()(回数)と with()(引数)で検証します。
Mockery
<?php
use PHPUnit\Framework\TestCase;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
class PointServiceMockeryTest extends TestCase
{
use MockeryPHPUnitIntegration; // Mockery::close() を自動化
public function test_保存と通知が1回ずつ呼ばれる(): void
{
$repository = Mockery::mock(PointRepository::class);
$notifier = Mockery::mock(Notifier::class);
$repository->shouldReceive('addPoints')->once()->with(100, 50);
$notifier->shouldReceive('notify')->once()
->with(100, Mockery::pattern('/50ポイント/'));
(new PointService($repository, $notifier))->grant(100, 50);
}
}
MockeryPHPUnitIntegration トレイトを使うと、期待値の検証と Mockery::close() が自動で走ります。手動なら tearDown() で Mockery::close() を呼びます。
戻り値を返すモック
// PHPUnit
$repository->method('findPoints')->with(100)->willReturn(200);
// Mockery
$repository->shouldReceive('findPoints')->with(100)->andReturn(200);
PHPUnitモックとMockeryの使い分け
| 観点 | PHPUnit標準 | Mockery |
|---|---|---|
| 追加依存 | 不要 | 必要 |
| 記述スタイル | やや手続き的 | 宣言的・流暢 |
| 引数マッチャ | callback() など |
Mockery::on() / pattern() が豊富 |
| 部分モック | やや手間 | makePartial() が簡単 |
| final/staticの扱い | 苦手 | 対応可(乱用注意) |
基本はPHPUnit標準で足り、複雑な引数マッチや部分モックが必要なときにMockeryを選ぶ、という判断が実践的です。
確認方法
vendor/bin/phpunit --testdox
Point Service ✔ 保存と通知が1回ずつ呼ばれる ✔ 不正なamountで例外 副作用なし
DB接続も外部API呼び出しも発生せず、テストが高速に完了すれば依存が正しく切れています。実接続が起きていれば、まだ依存が残っているサインです。
注意点
- モックはインターフェース(抽象)に対して行う。具象クラスのモックは実装変更に脆い。
- モックしすぎない。純粋な計算ロジックまでモック化すると、テストが実装のコピーになり壊れやすくなる。モックは境界(DB・外部API・時刻・乱数)に限定する。
Mockery::close()を必ず呼ぶ。呼ばないと期待値検証が走らず、テストが実質無意味になる。- static/finalメソッドのモックは最終手段。まず設計側で回避できないか検討する。
まとめ
テストが書けない主因は、多くの場合「依存を内部で new していること」です。インターフェース抽出とコンストラクタ注入でテスト対象をドメインロジックに絞り、PHPUnit標準モックを基本に、必要なときだけMockeryを足す。モックは境界にだけ使い、Mockery::close() を必ず走らせる——この2点を守れば、モックテストは十分に保守可能になります。まずは1クラス、依存を1つ切り出してモックに差し替えるところから始めてみてください。
