メインコンテンツまでスキップ

テストダブル: 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');
});

テストは、準備 (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 つの違いを隠します。区別が身につくまでは手で書きます

次に読む​

Footnotes​

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