テストダブル: Meszaros の 5 分類
「モックを使う」という言い方は広く通じますが、指しているものは場面ごとに違います。戻り値を決め打っているだけのこともあれば、呼ばれた回数を数えていることも、簡易的な本物を用意していることもあります。
これらは別の道具です。役割が違うので、選び方も、テストが壊れるタイミングも違います。
この章では 1 つの題材を 5 通りに書き換えます。同じことをしたいのに書き方が違う、のではありません。確かめたいものが違うから形が違う、という順序で見てください。
この章で学ぶこと
- Dummy・Stub・Spy・Mock・Fake がそれぞれ何を担うか
- Spy と Mock の境目
- 確かめたいものから、どの型を選ぶか
- ツールより先に手書きで書く理由
03 テストが設計を圧す の型 1 と型 2 で「差し替える」と書いたものの中身がこの章です。
この章で扱わないこと
| 観点 | 参照先 |
|---|---|
vi.fn / vi.mock / vi.spyOn の API | TypeScript 開発ツールチェーン |
| モックを使いすぎたときに何が起きるか | テスト戦略 |
Laravel の Mail::fake() など、フレームワークが用意する仕組み | Pest でテストを書く |
題材
注文を確定し、確定したことを通知する処理を使います。
interface Notifier
{
public function notify(string $to, string $message): bool;
}
class PlaceOrder
{
public function __construct(private Notifier $notifier) {}
public function execute(Order $order): void
{
$order->confirm();
$this->notifier->notify($order->customerEmail(), '注文を承りました');
}
}
この Notifier を 5 通りに差し替えます。コードは PHP で示しますが、後半で TypeScript の対応も出します。
Dummy: 渡すだけで使わない
引数を埋めるためだけに存在します。呼ばれることを想定していないので、中身は空か、呼ばれたら例外を投げます。
class DummyNotifier implements Notifier
{
public function notify(string $to, string $message): bool
{
throw new LogicException('このテストでは通知は呼ばれないはずです');
}
}
使うのは、通知を経由しない経路を確かめるときです。
test('確定できない注文は例外になる', function () {
$useCase = new PlaceOrder(new DummyNotifier());
expect(fn () => $useCase->execute($emptyOrder))
->toThrow(EmptyOrderException::class);
});
例外を投げる実装にしておくと、想定に反して呼ばれたときに気づけます。何もしない実装にすると、通知が呼ばれていても黙って通ります。
Stub: 決まった答えを返す
対象が問い合わせる先を置き換えます。テストが決めた値を返すだけで、記録も検証もしません。
class StubNotifier implements Notifier
{
public function __construct(private bool $result) {}
public function notify(string $to, string $message): bool
{
return $this->result;
}
}
test('通知に失敗しても注文は確定している', function () {
$useCase = new PlaceOrder(new StubNotifier(false));
$useCase->execute($order);
expect($order->isConfirmed())->toBeTrue();
});
このテストが確かめているのは注文の状態です。通知は「失敗する状況を作るための道具」でしかありません。
Spy: 呼ばれ方を記録する
対象が外へ出す指示を受け取り、記録します。記録するだけで、それ自体はテストを失敗させません。検証はテストの側で行います。
class SpyNotifier implements Notifier
{
public array $calls = [];
public function notify(string $to, string $message): bool
{
$this->calls[] = ['to' => $to, 'message' => $message];
return true;
}
}
test('確定したら顧客に通知する', function () {
$spy = new SpyNotifier();
$useCase = new PlaceOrder($spy);
$useCase->execute($order);
expect($spy->calls)->toHaveCount(1);
expect($spy->calls[0]['to'])->toBe('taro.test@example.com');
});
テストは、準備 (Arrange)・実行 (Act)・検証 (Assert) の 3 つの位置に分けて書きます。検証が Assert の位置にあるので、テストを上から読むと「何をして、何を確かめたか」が順に並びます。
Mock: 期待を先に置く
Spy と同じく呼ばれ方を見ますが、期待を Arrange の位置で宣言し、違反したときは自分でテストを落とします1。
use PHPUnit\Framework\Assert;
class MockNotifier implements Notifier
{
private int $actual = 0;
public function __construct(private int $expectedCalls) {}
public function notify(string $to, string $message): bool
{
$this->actual++;
if ($this->actual > $this->expectedCalls) {
Assert::fail('通知の呼び出しが多すぎます');
}
return true;
}
public function verify(): void
{
Assert::assertSame($this->expectedCalls, $this->actual, '通知の回数が期待と違います');
}
}
test('通知はちょうど 1 回だけ送られる', function () {
$mock = new MockNotifier(expectedCalls: 1);
$useCase = new PlaceOrder($mock);
$useCase->execute($order);
$mock->verify();
});
Spy との違いは 2 つです。期待をどこで書くか (Mock は事前、Spy は事後) と、誰が落とすか (Mock は自分、Spy はテスト) です。
呼びすぎた瞬間に落ちるので、失敗した箇所が近くなります。代わりに、テストを読んだとき何を確かめているのかが Arrange まで戻らないと分かりません。
Fake: 動く簡易実装
簡易な実装を持ちます。本物の代わりに実際に動きますが、本番では使えない近道をしています。
class InMemoryNotifier implements Notifier
{
private array $mailbox = [];
public function notify(string $to, string $message): bool
{
$this->mailbox[$to][] = $message;
return true;
}
public function messagesFor(string $to): array
{
return $this->mailbox[$to] ?? [];
}
}
Spy との違いは、呼ばれ方を記録するためではなく、本物の代わりに動かすために置くことです。状態を持って本物のように振る舞うので、複数の操作をまたぐテストが書けます。届いた通知を messagesFor() で読むテストは、Spy の記録を読むテストと見ているものが同じです。
保存したものの読み書きをまとめて引き受けるオブジェクトを、リポジトリと呼びます。これを配列で実装したものも Fake です。07 章で使います。
TypeScript で書くと
形は変わりません。Spy と Mock を対で示します。
// Spy: 記録するだけ
class SpyNotifier implements Notifier {
calls: Array<{ to: string; message: string }> = []
notify(to: string, message: string): boolean {
this.calls.push({ to, message })
return true
}
}
it('確定したら顧客に通知する', () => {
const spy = new SpyNotifier()
new PlaceOrder(spy).execute(order)
expect(spy.calls).toHaveLength(1)
expect(spy.calls[0].to).toBe('taro.test@example.com')
})
// Mock: 期待を先に置き、自分で落とす
class MockNotifier implements Notifier {
private actual = 0
private readonly expectedCalls: number
constructor(expectedCalls: number) {
this.expectedCalls = expectedCalls
}
notify(): boolean {
this.actual++
if (this.actual > this.expectedCalls) {
throw new Error('通知の呼び出しが多すぎます')
}
return true
}
verify(): void {
if (this.actual !== this.expectedCalls) {
throw new Error('通知の回数が期待と違います')
}
}
}
5 つの違いを 1 枚に
| 型 | 呼ばれることを想定するか | 値を返すか | 記録するか | 期待をどこで書くか | 落とすのは誰か |
|---|---|---|---|---|---|
| Dummy | しない | しない | しない | しない | — |
| Stub | する | する | しない | しない | — |
| Spy | する | する | する | Assert | テスト |
| Mock | する | する | する | Arrange | ダブル自身 |
| Fake | する | する | 状態として持つ | Assert | テスト |
境目は連続しています。形の上では、記録する Stub は Spy に近づき、状態を持つ Spy は Fake に近づきます。名前を正確に当てることが目的ではありません。確かめたいものに対して、余計な検証を持ち込んでいないかを見るための区別です。
どれを選ぶか
確かめたいものから決まります。
| 確かめたいもの | 選ぶ型 |
|---|---|
| 対象が返す値、対象の状態 | Stub (状況を作るため) |
| 外部への指示が出たこと | Spy |
| 呼びすぎが、どの呼び出しで起きたか | Mock |
| 複数の操作をまたいだ結果 | Fake |
| 何も | Dummy |
迷ったら Spy を選びます。記録だけして、確かめたいことを Assert に書けば足ります。回数そのものは、Spy でも記録の件数で確かめられます (上の toHaveCount(1))。Mock を選ぶのは、呼びすぎをその呼び出しの時点で落とし、どこで起きたかを示したい場面です。
手書きから始める理由
ここまで、すべて手で書きました。vi.fn() も Mockery も使っていません。
ツールを使えば短く書けます。ただしツールは 5 つの型の違いを隠します。vi.fn() は記録もするし戻り値も決められるので、Stub と Spy のどちらとしても通用してしまい、いま何を確かめているのかがコードに現れません。
手で書くと、クラスの形が役割を表します。StubNotifier は値を返すだけ、SpyNotifier は配列を持つ。読めば分かります。
型の違いが身についたらツールを使ってください。近道は、どこへ向かう道かが分かってから使うものです。
まとめ
- 5 つの型は、確かめたいものが違うから形が違います。名前を当てるためのものではありません
- Spy と Mock の差は、期待を書く位置 (事後か事前か) と、テストを落とす主体 (テストかダブル自身か) です
- 迷ったら Spy を選びます。Mock を選ぶのは、呼びすぎをその場で落としたいときです
- ツールは 5 つの違いを隠します。区別が身につくまでは手で書きます
次に読む
- 05 流儀と進める方向 — 本物や Fake に残った結果を見るか、Mock でやり取りを見るかは、流儀の違いとして語られてきました
- 07 境界を差し替える — この章のダブルを、実際の境界に当てはめます