テストダブル: 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');
});
検証が Assert の位置にあるので、テストを上から読むと「何をして、何を確かめたか」が順に並びます。
Mock: 期待を先に置く
Spy と同じく呼ばれ方を見ますが、期待を Arrange の位置で宣言し、違反したときは自分でテストを落とします1。
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) {
throw new ExpectationFailedException('通知の呼び出しが多すぎます');
}
return true;
}
public function verify(): void
{
if ($this->actual !== $this->expectedCalls) {
throw new ExpectationFailedException('通知の回数が期待と違います');
}
}
}
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 との違いは、問い合わせにも答えられることです。送った内容を後から読み出せるので、複数の操作をまたぐテストが書けます。
リポジトリを配列で実装したものも 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
constructor(private readonly expectedCalls: number) {}
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 に書けば足ります。Mock が要るのは、呼びすぎ自体が不具合になる場面 (課金、メール送信、外部 API の消費) に限られます。
手書きから始める理由
ここまで、すべて手で書きました。vi.fn() も Mockery も使っていません。
ツールを使えば短く書けます。ただしツールは 5 つの型の違いを隠します。vi.fn() は記録もするし戻り値も決められるので、Stub と Spy のどちらとしても通用してしまい、いま何を確かめているのかがコードに現れません。
手で書くと、クラスの形が役割を表します。StubNotifier は値を返すだけ、SpyNotifier は配列を持つ。読めば分かります。
型の違いが体に入ったらツールを使ってください。近道は、どこへ向かう道かが分かってから使うものです。
まとめ
- 5 つの型は、確かめたいものが違うから形が違います。名前を当てるためのものではありません
- Spy と Mock の差は、期待を書く位置 (事後か事前か) と、テストを落とす主体 (テストかダブル自身か) です
- 迷ったら Spy を選びます。Mock が要るのは呼びすぎ自体が不具合になるときです
- ツールは 5 つの違いを隠します。区別が身につくまでは手で書きます
次に読む
- 05 流儀と進める方向 — Stub 中心で書くか Mock 中心で書くかは、流儀の違いとして語られてきました
- 07 境界を差し替える — この章のダブルを、実際の境界に当てはめます