■はじめに
テストで出てくる「テストダブル」について、Jestを使って試してみた結果をまとめます!!
■テストダブルとは何か?
テストダブルとは、映画で使われている「スタントダブル」という言葉から作られた単語です。
Wikipediでは、以下の説明がされています。
スタントダブルとは、ボディダブルとスタントマンを掛け合わせたものであり、具体的には、映画やビデオの中で、ビルから飛び降りたり、車から車に乗り移ったりするような危険なシーンや、その他の高度なスタント(特に格闘シーン)に使用される熟練した代役のことである。
要するに、本物の俳優の代わりに、代わりにアクションを実施する代役のことです。
この「スタントダブル」から命名されたものが、「テストダブル」です。
「テストダブル」はWikipediaで以下の説明がされています。
テストダブル (Test Double) とは、ソフトウェアテストにおいて、テスト対象が依存しているコンポーネントを置き換える代用品のこと。ダブルは代役、影武者を意味する。
要するに、テストにおいて、代役を立てることを「テストダブル」といいます。
■テストダブルはなぜ必要なのか?
Aというロジックのテストを書こうと思った際、Aというロジックの中では、Bというロジックを実行していたとします。
この場合、「Aロジック」は「Bロジック」に依存しています。
この依存している「Bロジック」が
- ランダムな値を返す
- 外部APIを叩いて、結果を取得する
- DBに接続して、データを取得する
などを実行していたとします。
その場合、テスト実行時に結果が動的に変わったり、テストで外部APIを叩くのは現実的ではありません。
テストが通ったり、落ちたり、テストに時間がかかったりします。
その場合に、依存先のロジックをテスト用のコンポーネントと入れ替えるテクニックがあります。
この代用のコンポーネントを、テストダブルと呼びます。
■テストダブルにはどんなパターンがあるのか?
テストダブルには、以下5つのパターンがあります。
- テストスタブ (テスト対象に「間接的な入力」を提供するために使う。)
- テストスパイ (テスト対象からの「間接的な出力」を検証するために使う。出力を記録しておくことで、テストコードの実行後に、値を取り出して検証できる。)
- モックオブジェクト (テスト対象からの「間接的な出力」を検証するために使う。テストコードの実行前に、あらかじめ期待する結果を設定しておく。)
- フェイクオブジェクト (実際のオブジェクトに近い働きをするが、より単純な実装を使う。)
- ダミーオブジェクト (テスト対象のメソッドがパラメータを必要としているが、そのパラメータが利用されない場合に渡すオブジェクト。)
■スタブを使ってみる
スタブは、テスト時に使用される不完全なオブジェクトやコンポーネントです。
テスト対象の関数が必要とする、最小限の要素だけを持った簡易的なオブジェクトで、実装を単純化するために利用します。
// sample.ts
export type StringInfoType = {
lowerCase: string;
upperCase: string;
characters: string[];
length: number;
extractInfo: Object | undefined;
};
export const calculateComplexity = (stringInfo: StringInfoType): number => {
return Object.keys(stringInfo.extractInfo).length * stringInfo.length;
};
calculateComplexityメソッドのテストを書こうと思った場合、calculateComplexity内のロジックでは、stringInfo.extractInfo
とstringInfo.length
しか使っていません。
そのため、calculateComplexityのテストを書く場合、最低でもstringInfo.extractInfo
とstringInfo.length
だけあればテストを書くことができます。
test('Calculates complexity', () => {
const stringInfo = {
length: 5,
extractInfo: {
key1: 'value1',
key2: 'value2',
key3: 'value3',
},
};
const actual = calculateComplexity(stringInfo as unknown as StringInfoType);
expect(actual).toBe(15);
});
そのため、変数stringInfoはstringInfo.extractInfo
とstringInfo.length
だけを持つオブジェクトとしてテストを実行します。
テスト時には、完全なオブジェクトを作る代わりに、必要なプロパティだけを持つ簡略化されたオブジェクトを作成します。
これが「スタブ」です。
■フェイクを使ってみる
フェイクは、テスト時に使用される「簡略化された実際に動作する実装」のことです。
テスト対象のコードが依存する複雑なシステムや、外部サービスの代わりに、同じ機能を単純化して提供するオブジェクトなどです。
export const toUpperCaseWithCallback = (
arg: string,
callback: (callbackArg: string) => void
): string | undefined => {
if (!arg) {
callback('引数は空です');
return;
}
callback(`引数「${arg}」で関数を呼び出しました`);
return arg.toUpperCase();
};
引数で文字列と、コールバック関数を受け取って、処理を実行するtoUpperCaseWithCallbackのテストを書いてみます。
この関数のテストを書く場合、2つの引数が必要になります。
もし、第2引数のコールバック関数の処理が重要な処理ではない場合、コールバック関数が実行さえできればよいです。
そこで、第2引数には、簡略化したコールバック関数を渡します。
test('引数に空文字を渡した場合、undefinedが返ること', () => {
const actual = toUpperCaseWithCallback('', () => {});
expect(actual).toBeUndefined();
});
test('引数に小文字の文字列を渡した場合、大文字に変換されて返ってくること', () => {
const actual = toUpperCaseWithCallback('abc', () => {});
expect(actual).toBe('ABC');
});
このテストにおいて、第2引数は「簡略化された実際に動作する実装」を渡してます。
これを「ファイク」といいます。
「ファイク」により、テスト対象の関数の主要な機能(大文字変換)に集中できます。
しかし、「ファイク」を使うと、コールバック関数が重要な処理を行う場合に、
- コールバック関数に正しい引数が渡されたか?
- 実際に呼び出されたか?
を検証することはできません。
そのため、後に紹介する「モック」が必要になります。
■モックを使ってみる
前回「フェイク」を使うことで、テストを実行できました。
しかし、
- コールバック関数に正しい引数で呼ばれたのか?
- そもそも実際に呼び出されたか?
はわからない状況でした。
これら問題点を解消するには、特別なテストダブルが必要になります。
それが「モック」です。
今回やりたいことは、コールバックが呼び出されて実行される方法を追跡します。
◆コールバック関数をファイクして監視してみる
describe('コールバック関数のトラッキング', () => {
let callBackArgsTracking: string[] = [];
let countCalled = 0;
const callBackMock = (arg: string) => {
callBackArgsTracking.push(arg);
countCalled++;
};
afterEach(() => {
callBackArgsTracking = [];
countCalled = 0;
});
test('無効な引数に対して、コールバック関数が実行されること', () => {
const actual = toUpperCaseWithCallback('', callBackMock);
expect(actual).toBeUndefined();
expect(callBackArgsTracking).toContain('引数は空です');
expect(countCalled).toBe(1);
});
test('有効は引数に対して、コールバック関数が実行されること', () => {
const actual = toUpperCaseWithCallback('abc', callBackMock);
expect(actual).toBe('ABC');
expect(callBackArgsTracking).toContain('引数「abc」で関数を呼び出しました');
expect(countCalled).toBe(1);
});
});
今回、コールバック関数が
- どんな引数で呼ばれたのか?
- 何回呼ばれたのか?
をトラッキングしたいです。
そこで、toUpperCaseWithCallbackメソッドに渡すコールバック関数(フェイク)で、コールバック関数の引数に渡された値と、呼び出された回数をトラッキングします。
上のようなテストを書くことで、
- コールバック関数が何回呼ばれたのか?
- どんな引数で呼ばれたのか?
をテストで確認できるようになりました。
また、それぞれのテストでトラッキングを独立させるために、afterEachでトラッキング用の変数を初期化するようにしています。
◆モックを使って監視してみる
次にモック関数を使って
- コールバック関数が何回呼ばれたのか?
- どんな引数で呼ばれたのか?
をトラッキングしてみます。
describe('Jestのモックを使って、コールバック関数のトラッキング', () => {
const callBackMock = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
test('無効な引数に対して、コールバック関数が実行されること', () => {
const actual = toUpperCaseWithCallback('', callBackMock);
expect(actual).toBeUndefined();
expect(callBackMock).toHaveBeenCalledWith('引数は空です');
expect(callBackMock).toHaveBeenCalledTimes(1);
});
test('有効は引数に対して、コールバック関数が実行されること', () => {
const actual = toUpperCaseWithCallback('abc', callBackMock);
expect(actual).toBe('ABC');
expect(callBackMock).toHaveBeenCalledWith('引数「abc」で関数を呼び出しました');
expect(callBackMock).toHaveBeenCalledTimes(1);
});
});
jest.fn()
でモック関数を作成します。
jest.fn()
とだけ書いた場合は、処理を持たない、定義だけの「空の関数」を定義したことと同じになります。
引数を受け取っても何も処理せず、undefined
を返します。
しかし、
- 呼び出された回数
- 渡された引数
- 返した値
などの情報を記録することができます。
そして、モック化したcallBackMock
をtoUpperCaseWithCallback
の引数に渡すと、toUpperCaseWithCallback
内でcallBackMock
が実行されます。
そうすると、モック関数は内部で「呼び出された回数」「渡された引数」などをインスタンス変数として保持します。
そのため、
-
expect(callBackMock).toHaveBeenCalledWith('引数は空です');
:呼び出された引数のテスト -
expect(callBackMock).toHaveBeenCalledTimes(1);
:呼び出された回数のテスト
などをすることができます。
test('無効な引数に対して、コールバック関数が実行されること', () => {
const actual = toUpperCaseWithCallback('', callBackMock);
console.log(callBackMock.mock);
console.log(callBackMock.mock.calls);
console.log(callBackMock.mock.calls.length);
});
Jest内部では、callBackMock.mock
オブジェクトのプロパティで「呼び出された引数」や「呼び出された回数」を保持しています。
expect(callBackMock.mock.calls[0]).toBe('引数は空です');
expect(callBackMock.mock.length).toBe(1);
そのため、「toHaveBeenCalledWith」や「toHaveBeenCalledTimes」という用意された関数を使わないで、上のように書くこともできます。
// 実装を追加
const mockFn = jest.fn(() => 'hello');
// 返り値を設定
const mockFn = jest.fn().mockReturnValue('hello');
// 一度だけ特定の値を返す
const mockFn = jest.fn().mockReturnValueOnce('hello').mockReturnValueOnce('world');
また、Mock関数をカスタマイズして、
- 実装をもたせる
- 戻り値を決め打ちする
- モック関数が呼び出される事に返す値を返る
などもできます。
つまり、jest.fn()
は「動作はないけれど(動作をカスタマイズはできる)、呼び出しを記録する役者」のようなものになります。
テスト対象のコードが、正しく関数を呼び出していることを検証するために使われます。
■スパイを使ってみる
◆スパイで監視してみる
export class StringUtils {
private callExternalService() {
console.log('外部サービスを呼び出しました');
}
toUpperCase(arg: string): string {
return arg.toUpperCase();
}
logString(arg: string) {
console.log(arg);
}
}
今回spyを使って監視するのは、上のクラスとします。
describe('スパイの実行テスト', () => {
let sut: StringUtils;
beforeEach(() => {
sut = new StringUtils();
});
afterEach(() => {
jest.clearAllMocks();
});
test('spyを使って、メソッドの実行を監視', () => {
const toUpperCaseSpy = jest.spyOn(sut, 'toUpperCase');
const result = sut.toUpperCase('abc');
expect(result).toBe('ABC');
expect(toUpperCaseSpy).toHaveBeenCalledWith('abc');
expect(toUpperCaseSpy).toHaveBeenCalledTimes(1);
});
});
spyを使うには、jest.spyOn()
を使います。
spyOn()
関数の第1引数に監視したいオブジェクトを渡します。
第2引数には、監視したいメソッド名を渡します。
今回は、sutオブジェクトのtoUpperCaseメソッドを監視するため、jest.spyOn(sut, 'toUpperCase');
と書いています。
ここで注目したいのは
expect(result).toBe('ABC');
expect(toUpperCaseSpy).toHaveBeenCalledWith('abc');
expect(toUpperCaseSpy).toHaveBeenCalledTimes(1);
です。
まず、expect(result).toBe('ABC');
というテストが通るということは、jest.spyOn()
を使ってもtoUpperCase
メソッドのロジックは置き換えられていないことがわかります。
(jest.fn()
では、完全に置き換えられていました)
さらに、toHaveBeenCalledWith
やtoHaveBeenCalledTimes
を使って、呼び出された引数や回数も検証できています。
describe('スパイの実行テスト', () => {
let sut: StringUtils;
beforeEach(() => {
sut = new StringUtils();
});
afterEach(() => {
jest.clearAllMocks();
});
test('外部モジュールをスパイする', () => {
const consoleLogSpy = jest.spyOn(console, 'log');
sut.logString('abc');
expect(consoleLogSpy).toHaveBeenCalledWith('abc');
expect(consoleLogSpy).toHaveBeenCalledTimes(1);
});
});
spyを使って、外部モジュール(今回はグルーバルオブジェクトであるconsoleオブジェクトのlogメソッド)をスパイする事もできます。
describe('スパイの実行テスト', () => {
let sut: StringUtils;
beforeEach(() => {
sut = new StringUtils();
});
afterEach(() => {
jest.clearAllMocks();
});
test('スパイを使ってメソッドの実装を置き換える', () => {
jest.spyOn(sut as any, 'callExternalService').mockImplementation(() => {
return 'スパイ';
});
const result = sut['callExternalService']();
expect(result).toBe('スパイ');
});
});
また、mockImplementationを使って、メソッドの実装を置き換える事もできます。
◆モックとスパイの違い
モック(jest.fn())
- 新しい関数を作成:完全に新しい、空の関数を作ります
- 完全に制御:振る舞いを完全に制御でき、返り値や実装を定義できます
- 追跡機能:呼び出し回数や引数などを追跡します
- 用途:依存関係を置き換えたり、未実装の関数をシミュレートするのに適しています
// モックの例
const mockFunction = jest.fn();
// または実装を与える
const mockFunction = jest.fn(() => 'result');
スパイ(jest.spyOn())
- 既存の関数を監視:既存のオブジェクトのメソッドを監視します
- 元の実装を保持可能:デフォルトでは元の実装を保持したまま監視できます
- 追跡機能:モックと同様に呼び出し情報を追跡します
- 用途:既存のオブジェクトのメソッドの呼び出しを監視する場合に適しています
// スパイの例
const user = {
getName: () => 'John'
};
// getName()の呼び出しを監視するが、元の実装は保持
const spy = jest.spyOn(user, 'getName');
// 実装を置き換えることも可能
jest.spyOn(user, 'getName').mockImplementation(() => 'Jane');
主な違い
- 作成方法:モックは新しい関数を作り、スパイは既存の関数を監視します
- 元の実装:スパイはデフォルトで元の実装を実行しますが、モックは完全に置き換えます
-
復元可能性:スパイは
.mockRestore()
で元の実装に戻せますが、モックは新しい関数なので「元に戻す」概念がありません
スパイは「既存のものを監視する」時に使い、モックは「新しく作る」時に使う感じになります。
■ファイルレベルでMockする
◆ファイル全体をモックする方法
さて、jestにはファイル(モジュール)の機能全体を変更できる機能もあります。
// OtherUtils.ts
import { v4 } from "uuid";
export type StringInfoType = {
lowerCase: string;
upperCase: string;
characters: string[];
length: number;
extractInfo: Object | undefined;
};
export const toUpperCase = (arg: string): string => {
return arg.toUpperCase();
};
export const toLowerCaseWithId = (arg: string): string => {
return arg.toLowerCase() + v4();
};
export const calculateComplexity = (stringInfo: StringInfoType): number => {
return Object.keys(stringInfo.extractInfo).length * stringInfo.length;
};
OtherUtils.ts
ファイルの中身が、上のように関数をいくつもexportしているとします。
import * as OtherUtils from 'OtherUtils';
jest.mock('OtherUtils');
describe('モジュールMockのテスト', () => {
test('toUpperCase', () => {
const result = OtherUtils.toUpperCase('abc');
console.log(result);
});
});
OtherUtilsの全てのexportを読み込んで、Mockします。
ここで、toUpperCaseを実行してみます。
本来であれば「abc」が「ABC」になって返ってきます。
しかし、実際には「undefined」が返ってきます。
理由はファイル全体をjest.mock
でモックしたため、実装を持たない関数に置き換えられたためです。
◆ファイル内の一部をモックする方法
前回は、ファイル内の全てをMockしました。
しかし、ファイル内のいくつかの関数をモックしたいが、いくつかの関数の実装はそのままにしておきたい場合、どうすればよいでしょうか?
// OtherUtils.ts
import { v4 } from "uuid";
export type StringInfoType = {
lowerCase: string;
upperCase: string;
characters: string[];
length: number;
extractInfo: Object | undefined;
};
export const toUpperCase = (arg: string): string => {
return arg.toUpperCase();
};
export const toLowerCaseWithId = (arg: string): string => {
return arg.toLowerCase() + v4();
};
export const calculateComplexity = (stringInfo: StringInfoType): number => {
return Object.keys(stringInfo.extractInfo).length * stringInfo.length;
};
OtherUtils.ts
内のtoUpperCaseメソッドとv4();
メソッドのみをモックして、その他の関数はそのままのロジックを保持してテストを実施したいです。
import * as OtherUtils from 'OtherUtils';
jest.mock('OtherUtils', () => {
return {
...jest.requireActual('OtherUtils'),
// toUpperCase関数をモック化
toUpperCase: () => 'ABC',
};
});
jest.mock('uuid', () => {
return {
v4: () => '123',
};
});
describe('モジュールMockのテスト', () => {
afterAll(() => {
jest.clearAllMocks();
});
test('toUpperCase test', () => {
const result = OtherUtils.toUpperCase('');
// toUpperCaseはモック化されているので、必ず'ABC'が返ってくる。
expect(result).toBe('ABC');
});
test('toLowerCaseWithId test', () => {
const result = OtherUtils.toLowerCaseWithId('ABC');
// uuidのv4関数はモック化されているので、必ず'123'が返ってくる。そのため、'abc123'が返ってくる。
expect(result).toBe('abc123');
});
test('calculateComplexity test', () => {
const result = OtherUtils.calculateComplexity({
length: 5,
extractInfo: {
key1: 'value1',
key2: 'value2',
key3: 'value3',
},
} as unknown as OtherUtils.StringInfoType);
// calculateComplexityはモック化されているので、実際のロジックが実行された結果が返ってくる。
expect(result).toBe(15);
});
});
その場合、requireActual
を使います。
requireActualでOtherUtils.ts
内のexportされた関数を読みこんで、スプレッド演算子で展開します。
この時点では、OtherUtils.ts
内の関数をそのままコピーしただけですが、toUpperCase: () => 'ABC',
でtoUpperCase
関数だけを上書きしてMockしています。
また、'uuid'
ライブラリのv4メソッドは、ランダムな値が実行に作成されるため、テストすることは不可能です。
そのため、'uuid'
ライブラリのv4メソッドも、Mockすることで戻り値を固定しています。
このようにして、ファイル内の一部をモックする事が可能です。
■まとめ
JestのMock方法は色々あるので、適材適所で使っていきたいです。