🎪

【Jest】モック関数入門

2023/05/03に公開2

はじめに

Jestではじめるテスト入門
フロントエンド開発のためのテスト入門が届き、
テスト欲が高まっている土屋です。

Jestでの単体テストを行う際に、
「似たようなメソッドがあるけど、何が違うんだ?」という疑問が生まれたため、
前述の2つの本を参考にJestでのモックの方法を整理していきたいと思います。

https://peaks.cc/books/testing_with_jest
https://www.shoeisha.co.jp/book/detail/9784798178639

モックとは


xUnit Patterns Test Doubleより引用

モックとは、テスト対象の一部もしくは全てを代替オブジェクトで代用する手法であり、
広義の意味では「テストダブル」と呼ばれています。
xUnit Patterns によると、テストダブルには

  • ダミー: コンパイルを通すために必要なコンポーネント
  • スタブ: 代替オブジェクトで設定した値を返す
  • スパイ: パラメータ、呼び出された回数、戻り値などを記録
  • (狭義の意味での)モック: 代替オブジェクトで期待する内容の検証
  • フェイク: 実際の依存コンポーネントよりも軽量な代替オブジェクト

などのサブクラスがあります。

http://xunitpatterns.com/Test Double Patterns.html

jest.fn()

jest.fn()は、モックの土台となるメソッドで、
新しいモックオブジェクトを作成します。
作成されたモックオブジェクトはmockプロパティをもち、
関数が呼び出された際の引数や結果を保持できるようになります。

test("jest.fn()のサンプル", () => {
	const mockFn = jest.fn();
	mockFn();
	mockFn(1, 2, 3);

	// mockFnが2回呼ばれていること	
	expect(mockFn).toHaveBeenCalledTimes(2);
	
	// mockFnの引数に1, 2, 3が渡されていること
	expect(mockFn).toHaveBeenCalledWith(1, 2, 3);
	
	// mockFnの引数に1, 2, 3, 4が渡されていないこと
	expect(mockFn).not.toHaveBeenCalledWith(1, 2, 3, 4);

});

https://jestjs.io/ja/docs/mock-function-api

jest.mock()

jest.mock()は 既存のモジュールの一部もしくは全てを上書きしてモック化します。

モジュールの一部をモック化する場合

例として、戦闘力を計測するプログラムのテストを書いてみます。

calc.ts
export const tenThousandTimes = (fightingPower: number) => fightingPower * 10000;

export const immeasurable = () => "計測不可能";
scouter.ts
import { tenThousandTimes, immeasurable } from "./calc";

export const scouter = (fightingPower: number) => {
	const calculatedFightingPower = fightingPower <= 53 ? tenThousandTimes(fightingPower) : immeasurable();
	
	return `私の戦闘力は${calculatedFightingPower}です。`;
};

scouter の関数内ではtenThousandTimes を使用しており、
tenThousandTimes に依存した状態になっています。

jest.mock() を使い、tenThousandTimes の関数をモック化してみます。
jest.mock("対象のモジュールのパス", モックしたい関数) の形で記載します。

scouter.test.ts
import { scouter } from "./scouter";

// ./calc.tsのtenThousandTimesをモック化
jest.mock("./calc", () => ({
	tenThousandTimes: (fightingPower: number) => `${fightingPower}`,
}));

test("戦闘力 53万", () => {
	const result = scouter(53);
	
	expect(result).toBe("私の戦闘力は53万です。");
	expect(result).not.toBe("私の戦闘力は530000です。");
});

test("戦闘力 計測不可能", () => {
	const result = scouter(100);
	
	expect(result).toBe("私の戦闘力はundefinedです。");
	expect(result).not.toBe("私の戦闘力は計測不可能です。");
});

test(戦闘力 53万)の方は、

// ./calc.tsのtenThousandTimesをモック化
jest.mock("./calc", () => ({
	tenThousandTimes: (fightingPower: number) => `${fightingPower}`,
}));

にてモック化されているため、
モックで書き換えた「私の戦闘力は53万です。」が返却されています。

一方で、test("戦闘力 計測不可能") では、
jest.mock() の中でモックされていないため、
実行結果はundefinedとなり、「私の戦闘力はundefinedです。」が返却されてしまっています。

immeasurable をモック化しない場合はjest.mock() を下記のように書き換えます。

import { scouter } from "./scouter";


// ./calc.tsのtenThousandTimesをモック化
jest.mock("./calc", () => {
+	const originalModule = jest.requireActual("./calc");
+	return {
+		...Object.assign({}, originalModule),
		tenThousandTimes: (fightingPower: number) => `${fightingPower}万`,
	};
});

  

test("戦闘力 53万", () => {
	const result = scouter(53);
	
	expect(result).toBe("私の戦闘力は53万です。");
	expect(result).not.toBe("私の戦闘力は530000です。");
});

  

test("戦闘力 計測不可能", () => {
	const result = scouter(100);

-       expect(result).not.toBe("私の戦闘力はundefinedです。");
-       expect(result).toBe("私の戦闘力は計測不可能です。");
+	expect(result).toBe("私の戦闘力は計測不可能です。");
+	expect(result).not.toBe("私の戦闘力はundefinedです。");
});

モジュールの全てをモック化する場合

モジュールを全てモック化する場合は、
jest.mock("対象のモジュールのパス")のように記載します。
axios などの外部のモジュールをモック化することも可能です。

import * as calc from "./calc";
import { scouter } from "./scouter";

  

// ./calc.tsを全てモック化
jest.mock("./calc");

test("戦闘力 530", () => {
	(calc as jest.Mocked<typeof calc>).tenThousandTimes.mockImplementation(
		(fightingPower: number) => fightingPower * 10,
	);
	const result = scouter(53);
	
	expect(result).toBe("私の戦闘力は530です。");
	expect(result).not.toBe("私の戦闘力は530000です。");
});

ポイントとしては、
モジュールを
import * as calc from "./calc"; の形式でインポートすることと、

(calc as jest.Mocked<typeof calc>).tenThousandTimes.mockImplementation(
		(fightingPower: number) => fightingPower * 10,
	);

の部分で、(jest.Mockedにキャストした対象のモジュール).関数名として、
mockImplementationでモックの中身を記載していく点になります。

https://jestjs.io/ja/docs/jest-object#jestmockmodulename-factory-options

jest.spyOn()

jest.spyOn()jest.mock() 同様に、既存の関数をモック化します。
jest.mock()との相違点としては

  • jest.spyOn() は既存のオブジェクトの特定の関数をモック化する
    • jest.mock() は既存のモジュールの一部もしくは全てをモック化
  • オリジナルの関数に戻すことができる

の2点になります。

使い方は、対象のオブジェクトをimport * as calc from "./calc"; の形でインポートし、
jest.spyOn(対象のオブジェクト, "モック化したい関数名") としてモック化します。

import * as calc from "./calc";
import { scouter } from "./scouter";

  
// ここではtenThousandTimesがundefinedになる
// jest.spyOn(calc, "tenThousandTimes").mockImplementation((fightingPower: number) => fightingPower / 10000);

test("戦闘力 0.0053", () => {
	jest.spyOn(calc, "tenThousandTimes").mockImplementation((fightingPower: number) => fightingPower / 10000);
	const result = scouter(53);
	
	expect(result).toBe("私の戦闘力は0.0053です。");
	expect(result).not.toBe("私の戦闘力は530000です。");
});

モックをオリジナルの関数に戻す場合は、
afterEach() の中で、jest.restoreAllMocks() することでモックを戻すことができます。

import * as calc from "./calc";
import { scouter } from "./scouter";


// ここではtenThousandTimesがundefinedになる
// jest.spyOn(calc, "tenThousandTimes").mockImplementation((fightingPower: number) => fightingPower / 10000);

afterEach(() => {
	jest.restoreAllMocks();
});

  

test("戦闘力 0.0053", () => {
	jest.spyOn(calc, "tenThousandTimes").mockImplementation((fightingPower: number) => fightingPower / 10000);
	const result = scouter(53);

	// spyOnしているので、モックした値になる
	expect(result).toBe("私の戦闘力は0.0053です。");
	expect(result).not.toBe("私の戦闘力は530000です。");
});

  

test("戦闘力 5300000", () => {
	const result = scouter(53);

	// オリジナルの関数の呼び出し結果になる
	expect(result).toBe("私の戦闘力は530000です。");
});

https://jestjs.io/ja/docs/jest-object#jestspyonobject-methodname

まとめ

  • jest.fn() : モックの土台となるオブジェクトを作成する
  • jest.mock() : モジュールの一部もしくは全部をモック化する
  • jest.spyOn() : 規定のオブジェクトの特定の関数をモック化する
    • モックはオリジナルの関数に戻すこともできる

終わりに

自分の設定が不足していたためか、
公式ドキュメント通り動かない箇所があり結構ハマりました。。
また、メソッドごとにモック化する位置によって動く・動かないなどもあったため、
この辺りもしっかり理解していきたいと思います、、!

個人的には、内部の既存のモジュールをモック化する際は、
jest.mock()よりもjest.spyOn()の方がシンプルで使いやすかったです。

より実践的なテストについても、機会があればまとめていきたいと思います。

参考

https://peaks.cc/books/testing_with_jest
https://www.shoeisha.co.jp/book/detail/9784798178639
https://jestjs.io/ja/docs/api
https://qiita.com/marchin_1989/items/3abaf7d57c501bb2c5a6
http://xunitpatterns.com/index.html
https://zenn.dev/dove/scraps/e537b453395ea8

Aidemy Tech Blog

Discussion

mitsuakimitsuaki

参考になりました、ありがとうございます。

なお、jest.Mockedによる型アサーションの部分はjest.mockedを使えば少し短く書けます👍

- (calc as jest.Mocked<typeof calc>).tenThousandTimes.mockImplementation(
+ jest.mocked(calc).tenThousandTimes.mockImplementation( 
  (fightingPower: number) => fightingPower * 10,
);

https://jestjs.io/docs/mock-function-api#jestmockedsource-options