TypeScript で説明するスタブ、モック、スパイ、フェイク、ダミー

TypeScript で説明するスタブ、モック、スパイ、フェイク、ダミー

June 18, 2023

テストに関して、掲題をはじめとした用語の理解を整理します。

テストダブルとその5つの分類 #

テスト時には、テスト対象(*)が実際に依存しているものをしばしば置き換えて実行することがあります。

*テスト対象は System Under Test と呼ばれ「SUT」と称されることが多いです。

具体的には、実際に依存しているクラスや関数などを、仮のクラス/関数で置き換えるわけですが、こういった置き換え用のクラス/関数を「テストダブル(Test Double)」といいます。

テストダブルの由来はダブルという職業から来ているようです。

例えばスタントマン(英語では Stunt double)は、俳優に変わって危険なシーンの撮影を担当する職業です。

XUnitPatterns の考え方によると、テストダブルは次の5つに分類されます。

  1. スタブ(Test Stub)
  2. モック(Mock Object)
  3. スパイ(Test Spy)
  4. フェイク(Fake Object)
  5. ダミー(Dummy Object)

以降では、この具体例を見ていきます。

なお、XUnitPatterns の考え方は有名ではあるものの、標準またはデファクトスタンダードと呼べるようなものでもありません。そのため、人・組織によって上記とは異なる分類方法を用いている場合もあります。上記の分類、名称に従っていないことが間違っているとは必ずしも言えないことにご留意ください。

ちなみに、日本ではテストダブルのこと指してモックと呼んでいることも多いように思います。そのため、その人がテストダブルのことを指してモックと呼んでいるのか、その中の1分類(モックオブジェクト)のことを指してモックと呼んでいるのか、意識して聞く必要があるかもしれません。

コード #

ここに以下の Entity、Repository(のインターフェース)、UseCase があるとします。

entity.ts

export default class UserEntity {
  public readonly id: string;
  public name: string;

  private constructor(params: { id: string; name: string }) {
    this.id = params.id;
    this.name = params.name;
  }

  public updateName(name: string): void {
    this.name = name;
  }
}

repository.ts

import UserEntity from './entity.ts';

export default interface IUserRepository {
  save(data: UserEntity): Promise<void>;
  find(id: string): Promise<UserEntity>;
}

use-case.ts

import type IUserRepository from './repository.ts';

export default class UserUpdateUseCase {
  public constructor(private readonly userRepository: IUserRepository) {}

  public async run(params: { userId: string; name: string }): Promise<void> {
    const user = userRepository.find(params.userId);

    user.updateName(params.name);

    await this.userRepository.save(user);
  }
}

間接入力と間接出力 #

間接入力と間接出力について説明します。

間接入力はインターフェース/シグネチャから見えないテスト対象への入力で、間接出力はインターフェース/シグネチャから見えないテスト対象の出力です。

まず、先に掲示したユースケースのインターフェースと、run メソッドのシグネチャは以下と推定できます。

// ユースケースのインターフェース
interface UserUpdateUseCase {
  run(params: { userId: string; name: string }): Promise<void>;
}

// run メソッドのシグネチャ
run(params: { userId: string; name: string }): Promise<void>;

run メソッドは userIdname を持ったオブジェクト params を引数として受け取り、Promise<void> を返します。

言うなれば、params が「直接入力」であり、Promise<void> が「直接出力」です。

これに対して、シグネチャでは表現されないが、ユースケースの内部にて外部からデータを受け取っている、または外部へ受け渡しているものが「間接入力」あるいは「間接出力」です。

先に掲示したコードだと以下にあたります。

import type IUserRepository from './repository.ts';

export default class UserUpdateUseCase {
  public constructor(private readonly userRepository: IUserRepository) {}

  public async run(params: { userId: string; name: string }): Promise<void> {
    const user = userRepository.find(params.userId); // 👈 間接入力

    user.updateName(params.name); // 👈 間接出力

    await this.userRepository.save(user); // 👈 間接出力
  }
}

スタブ、モック #

スタブとモックについて説明します。

スタブ #

スタブは、テストダブルのうち、テスト対象(SUT)への「間接入力を置き換える」ものを指します。

次のコード例をご覧ください。ユースケースが内部で userRepository.find() を実行して取得する値を置き換えています。これをスタブと呼びます。

// 説明のための擬似コードです。実際にはエラーになる部分などがあります。

import UserEntity from './entity.ts';
import UserUpdateUseCase from './use-case.ts';
import type IUserRepository from './repository.ts';

// 👇 スタブ
const userRepository: IUserRepository = {
  find() {
    return new UserEntity({ id: '123', name: 'my old name' });
  },
};

const useCase = new UserUpdateUseCase(userRepository);
const params = { userId: '123', name: 'my new name' };

await useCase.run(params);

モック #

モックは、テストダブルのうち、テスト対象(SUT)への「間接出力を取得・評価する」ものを指します。

次のコード例をご覧ください。ユースケースが内部で userRepository.save() を実行して出力する値を取得し、その妥当性を評価しています。これをモックと呼びます。

// 説明のための擬似コードです。実際にはエラーになる部分などがあります。

import UserEntity from './entity.ts';
import UserUpdateUseCase from './use-case.ts';
import type IUserRepository from './repository.ts';

// 👇 モック
const userRepository: IUserRepository = {
  save(user: UserEntity) {
    if (user.name !== 'my new name') {
      throw Error();
    }
  },
};

const useCase = new UserUpdateUseCase(userRepository);
const params = { userId: '123', name: 'my new name' };

await useCase.run(params);

スパイ(とモック) #

スパイ #

スパイは、テストダブルのうち、テスト対象(SUT)への「間接出力を取得・保管する」ものを指します。

次のコード例をご覧ください。ユースケースが内部で userRepository.save() を実行して出力する値を取得、保管しています。これをスパイと呼びます。

// 説明のための擬似コードです。実際にはエラーになる部分などがあります。

import UserEntity from './entity.ts';
import UserUpdateUseCase from './use-case.ts';
import type IUserRepository from './repository.ts';

// 👇 スパイ
const userRepository: IUserRepository = {
  inputUserName = '';
  save(user: UserEntity) {
    this.inputUserName = user.name;
  },
};

const useCase = new UserUpdateUseCase(userRepository);
const params = { userId: '123', name: 'my new name' };

await useCase.run(params);

// スパイで保管した情報を使って評価する。
assert(userRepository.inputUserName === params.name);

スパイとモックはどちらも間接出力を取得する点において似ていますが、モックはその場で情報の妥当性を評価するのに対し、スパイは情報を保管するだけです。スパイが保管した情報を用いて、後ほど評価を行ったりします。

フェイクとダミー #

フェイク #

フェイクは、テストダブルのうち、差し替え元と同じように挙動するものを指します。

ここで IUserRepository の定義は次の通りでした。

import UserEntity from './entity.ts';

export default interface IUserRepository {
  save(data: UserEntity): Promise<void>;
  find(id: string): Promise<UserEntity>;
}

ユースケースはこれを実装した DatabaseUserRepository を使用しているとします。このリポジトリは、データをリモートのデータベースへ保存、取得します。

ただし、テスト時はリモートのデータベースの代わりに、インメモリへ保存、取得することで代替することとします。その実装として InMemoryUserRepository を作成しました。

これらは内部的には別々のソースへデータを保存していますが、使用者側から見ると挙動は全く変わりません。

このように差し替え元と同じように挙動するものをフェイクと呼びます。

ダミー #

ダミーは、テストダブルのうち、テストに一切の影響を与えないものを指します。

無理矢理な例ですが以下をご覧ください。

function sum(a: number, b: number, doYouLikeSushi: boolean): number {
  return a + b;
}

const dummy = true;

const result = sum(1, 2, dummy);
assert(result === 3);

sum() を実行するには第3引数まで渡す必要がありますが、この値がどうであろうが結果は影響されません。つまり、テストを実行するために必要だが、テストには影響しないものです。これをダミーと呼びます。

そういう意味では、テスト対象(SUT)はダミーに依存しているとは言えないため、そもそもダミーはテストダブルには含まれないという考え方もあるようです。

以上 #

スタブ、モック、スパイ、フェイク、ダミーについてでした!

参考 #