Skip to main content

テストダブル: Meszaros の 5 分類

「モックを使う」という言い方は広く通じますが、指しているものは場面ごとに違います。戻り値を決め打っているだけのこともあれば、呼ばれた回数を数えていることも、簡易的な本物を用意していることもあります。

これらは別の道具です。役割が違うので、選び方も、テストが壊れるタイミングも違います。

この章では 1 つの題材を 5 通りに書き換えます。同じことをしたいのに書き方が違う、のではありません。確かめたいものが違うから形が違う、という順序で見てください。

この章で学ぶこと

  • Dummy・Stub・Spy・Mock・Fake がそれぞれ何を担うか
  • Spy と Mock の境目
  • 確かめたいものから、どの型を選ぶか
  • ツールより先に手書きで書く理由
前提知識

03 テストが設計を圧す の型 1 と型 2 で「差し替える」と書いたものの中身がこの章です。

この章で扱わないこと

観点参照先
vi.fn / vi.mock / vi.spyOn の APITypeScript 開発ツールチェーン
モックを使いすぎたときに何が起きるかテスト戦略
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 つの違いを隠します。区別が身につくまでは手で書きます

次に読む

Footnotes

  1. 5 つの分類と「テストダブル」という総称は Gerard Meszaros の『xUnit Test Patterns』(2007) によります。Spy と Mock の差を「テストの失敗がどこで起きるか」に置く整理も同書に由来します。本章の説明は逐語の引用ではありません。