背景
雰囲気でjest.spyOn()
やjest.mock()
を使っていたところ、テスト間でMockが不本意に共有されてしまい、ある順番では通るけど、順番を入れ替えるとエラーを吐いてしまうという状況に出くわしました。(原因はMockしたモジュールを復元できていなかったことによるものでした)
これを機に、Mockの種類や初期化方法についてまとめてみようと思います。
前提
-
Node
-
npm
-
Jest基礎知識
準備
# 作業フォルダに移動後、以下のコマンドを実行してください。
$ mkdir jest-practice
$ cd jest-practice
$ npm init -y
$ npm install -D jest
$ touch utils.js main.js main.test.js
各ファイルを以下のように修正します。
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
module.exports = { add, multiply };
const utils = require('./utils');
function add5(num) {
return utils.add(num, 5);
}
function multiply5(num) {
return utils.multiply(num, 5);
}
function runCallback(cb) {
return cb();
}
module.exports = { add5, multiply5, runCallback };
const main = require('./main');
describe('test', () => {
test('formulas', () => {
expect(main.add5(1)).toEqual(6);
expect(main.add5(2)).toEqual(7);
expect(main.multiply5(3)).toEqual(15);
expect(main.multiply5(4)).toEqual(20);
});
test('callback execution', () => {
const cb = () => 'Callback';
expect(main.runCallback(cb)).toEqual('Callback');
});
});
※package.jsonはscriptsを以下のように変更してください。他はそのままでOKです。
"scripts": {
"test": "jest --watchAll"
}
最終的な構成は以下のようになります。
$ tree -L 1
.
├── main.js
├── main.test.js
├── node_modules
├── package-lock.json
├── package.json
└── utils.js
テストが通ればOKです。
$ npm test
PASS ./main.test.js
test
✓ formulas (3 ms)
✓ callback execution (1 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 0.169 s, estimated 1 s
Mockの種類
Jestには、3つのMock方法が存在します。
-
jest.fn()
-> Functionに対するMock
最もシンプルな方法で、対象のFunctionに割り当てる形で使用します。
Callbackとしてテスト対象のFunctionに渡し、その渡し先でコールされたかどうかをSpyすることも可能です。
ただし、以下の点で注意が必要です。
-
割り当て後にオリジナルの処理を復元できないこと。
=> テスト間でMockの状態を独立して管理したいときに対応できません。 - 仮にモジュール全体をMockしたい場合は、モジュールエクスポートされているFunction全てに対して、個別に割り当てる必要があること。
=> 現実的ではないので、モジュール全体をMockしたいときは、jest.mock()が利用されます。
例)
const main = require('./main');
const utils = require('./utils');
describe('test', () => {
// MockしたいFunctionに割り当てる
test('formulas', () => {
// formulasテスト内で宣言していますが、これは他テストでもMockされた状態となります。
utils.add = jest.fn();
utils.multiply = jest.fn();
main.add5(1);
main.add5(2);
main.multiply5(3);
main.multiply5(4);
expect(utils.add).toHaveBeenCalledTimes(2);
expect(utils.multiply).toHaveBeenCalledTimes(2);
});
// CallbackとしてMock Functionを定義する
test('callback execution', () => {
const cb = jest.fn();
main.runCallback(cb);
expect(cb).toHaveBeenCalledTimes(1);
});
});
-
jest.mock()
-> モジュール全体に対するMock
モジュール内の全てのエクスポートをMockしてくれます。
ただし、以下の点で注意が必要です。
- オリジナル処理の復元が難しいこと
=>jest.fn()
と同様にテスト間の独立性を保つときに、対応できません。
※jest.requireActual()
を用いることで状況によっては復元できますが、該当するケースは少ないと思います。
例)完全にモジュール全体をMockする場合
const main = require('./main');
const utils = require('./utils');
// 対象のモジュールパスに対してMockを宣言します。
// ※describeやtest内ではなく、モジュールスコープで宣言する必要があります。
jest.mock('./utils');
describe('test', () => {
test('formulas', () => {
main.add5(1);
main.add5(2);
main.multiply5(3);
main.multiply5(4);
expect(utils.add).toHaveBeenCalledTimes(2);
expect(utils.multiply).toHaveBeenCalledTimes(2);
});
});
例)一部のエクスポートのみMockする場合
const main = require('./main');
// 対象のモジュールパスに対してMockを宣言し、詳細を設定します。
jest.mock('./utils', () => {
const originalModule = jest.requireActual('./utils');
// add以外はオリジナル処理を利用し、addに関しては独自の処理を追加
return {
...originalModule,
add: () => 'mocked add',
// 以下でもOKです。もちろんtest内のassertionは変更する必要はありますが。
// add: jest.fn(),
};
});
describe('test', () => {
test('formulas', () => {
expect(main.add5(1)).toEqual('mocked add');
expect(main.multiply5(2)).toEqual(10);
});
});
-
jest.spyOn()
-> Functionに対するMockもしくはSpy
Functionの処理自体はオリジナルのものを使いつつも、そのFunctionに対するコール情報を観察したいときに有用です。また、上記の2つの方法とは違い、オリジナルの処理を復元することが可能なため、テスト間でMockに関する独立性を担保できます。
個人的には、Mockする際にjest.spyOnを最優先で考え、あとはケースに応じて、jest.mock()やjest.fn()を使用しています。
例)
const utils = require('./utils');
const main = require('./main');
describe('test', () => {
afterEach(() => {
// jest.spyOn()で設定された処理をオリジナルに戻します。
jest.restoreAllMocks();
});
test('formulas', () => {
// addにはSpyのみ設定し、multiplyはMockかつ戻り値を任意のものに設定
jest.spyOn(utils, 'add');
jest.spyOn(utils, 'multiply').mockReturnValue('mocked multiply');
// 処理はオリジナルのままですが、Call状態などを確認できます。
expect(main.add5(1)).toEqual(6);
expect(utils.add).toHaveBeenCalledTimes(1);
expect(main.multiply5(2)).toEqual('mocked multiply');
});
test('other test', () => {
// オリジナルの処理が動いているので、上記testのMockによる影響を受けていないことが分かります。
expect(main.multiply5(3)).toEqual(15);
});
});
Mock情報の初期化
Mockしたものは、コール情報や戻り値、処理など全て同テストモジュール内で記録・保持されます。つまり、テスト間でMockした情報は良くも悪くも共有されます。したがって、テスト間で意識的に初期化しない限り、予期せぬエラーや想定外の結果が返ってきてしまいます。
Jestには、clear
、reset
、restore
という文言が初期化という文脈で登場し、それぞれが異なる動きをするので説明します。
- clear
ここに記載のプロパティに関して、初期化が行われます。分かりやすいケースでいうと、コールされた履歴がクリアされます。
ただし、Mockに関する一部のプロパティが初期化されるだけで、MockしたモジュールやFunctionが復元されるわけではないので注意してください。
Mock Functionを個別でclearしたい場合は、mockFn.mockClear()
、すべてのMockをclearしたい場合は、jest.clearAllMocks()
を使用します。
例)
const utils = require('./utils');
const main = require('./main');
// モジュール全体をMockします。
// ※Mockの仕方はなんでも良いです。
jest.mock('./utils');
describe('jest.clearAllMocks()', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('formulas', () => {
main.add5(1);
main.multiply5(2);
expect(utils.add).toHaveBeenCalledTimes(1);
expect(utils.multiply).toHaveBeenCalledTimes(1);
});
test('something different', () => {
main.add5(1);
main.multiply5(2);
// Mock情報をクリアされているので、このテストでCallされた情報のみ残っています。
expect(utils.add).toHaveBeenCalledTimes(1);
expect(utils.multiply).toHaveBeenCalledTimes(1);
});
});
describe('mockFn.mockClear()', () => {
test('formulas', () => {
main.add5(1);
main.multiply5(2);
expect(utils.add).toHaveBeenCalledTimes(1);
expect(utils.multiply).toHaveBeenCalledTimes(1);
// addのみ初期化
utils.add.mockClear();
});
test('something different', () => {
main.add5(1);
main.multiply5(2);
// multiplyに関しては、前テストの情報が保持されたままなので、このテストでのコールを含めて2回呼ばれたことになります。
expect(utils.add).toHaveBeenCalledTimes(1);
expect(utils.multiply).toHaveBeenCalledTimes(2);
});
});
- reset
ここに記載のように、clear処理 + 独自で設定したMock戻り値やMock処理が初期化されます。
ただしこちらも、Mockに関する一部のプロパティが初期化されるだけで、MockしたモジュールやFunctionが復元されるわけではないので注意してください。
Mock Functionを個別でresetしたい場合は、mockFn.mockReset()
、すべてのMockをresetしたい場合は、jest.resetAllMocks()
を使用します。
例)
const utils = require('./utils');
const main = require('./main');
// モジュール全体をMockします。
// ※Mockの仕方はなんでも良いです。
jest.mock('./utils');
describe('jest.resetAllMocks()', () => {
afterEach(() => {
jest.resetAllMocks();
});
test('formulas', () => {
utils.add.mockReturnValue('mocked return value');
utils.multiply.mockImplementation(() => 'mocked implementation');
expect(main.add5(1)).toEqual('mocked return value');
expect(main.multiply5(2)).toEqual('mocked implementation');
});
test('something different', () => {
// clearの際と同様にコール情報も初期化されます。
expect(utils.add).toHaveBeenCalledTimes(0);
expect(utils.multiply).toHaveBeenCalledTimes(0);
// 独自で設定したMock戻り値や、処理が初期化されており、MockしたFunctionはundefinedを返します。
expect(main.add5(1)).toEqual(undefined);
expect(main.multiply5(2)).toEqual(undefined);
});
});
describe('mockFn.mockReset()', () => {
test('formulas', () => {
utils.add.mockReturnValue('mocked return value');
utils.multiply.mockImplementation(() => 'mocked implementation');
expect(main.add5(1)).toEqual('mocked return value');
expect(main.multiply5(2)).toEqual('mocked implementation');
utils.add.mockReset();
});
test('something different', () => {
// addのコール情報は初期化されています。
expect(utils.add).toHaveBeenCalledTimes(0);
expect(utils.multiply).toHaveBeenCalledTimes(1);
// addのMock戻り値は初期化されています。
expect(main.add5(1)).toEqual(undefined);
expect(main.multiply5(2)).toEqual('mocked implementation');
});
});
- restore
ここに記載のように、jest.spyOn()
やjest.replaceProperty()
でMockした処理を復元します。
Mock Functionを個別でrestoreしたい場合は、mockFn.mockRestore()
、すべてのMockをrestoreしたい場合は、jest.restoreAllMocks()
を使用します。
例)
const utils = require('./utils');
const main = require('./main');
describe('jest.restoreAllMocks()', () => {
afterEach(() => {
jest.restoreAllMocks();
});
test('formulas', () => {
jest.spyOn(utils, 'add').mockReturnValue('mocked return value');
jest.spyOn(utils, 'multiply').mockImplementation(() => 'mocked implementation');
expect(main.add5(1)).toEqual('mocked return value');
expect(main.multiply5(2)).toEqual('mocked implementation');
});
test('something different', () => {
// Mockした内容がオリジナルの処理に戻ります。
expect(main.add5(1)).toEqual(6);
expect(main.multiply5(2)).toEqual(10);
});
});
describe('mockFn.mockRestore()', () => {
test('formulas', () => {
jest.spyOn(utils, 'add').mockReturnValue('mocked return value');
jest.spyOn(utils, 'multiply').mockImplementation(() => 'mocked implementation');
expect(main.add5(1)).toEqual('mocked return value');
expect(main.multiply5(2)).toEqual('mocked implementation');
utils.add.mockRestore();
});
test('something different', () => {
// addのコール情報は初期化されています。
// ※expect(xxx)には、MockやSpyされたFunctionしか渡せません。そのためaddは省略しています。
expect(utils.multiply).toHaveBeenCalledTimes(1);
// addのMock戻り値は初期化されています。
expect(main.add5(1)).toEqual(6);
expect(main.multiply5(2)).toEqual('mocked implementation');
});
});
- 初期化設定
統一的にMockに関する初期化を行いたい場合は、以下のようにpackage.json
に配置可能です。この設定により、テスト間でclear、reset、restore処理が自動で行われます。
{
"dependencies": {
xxxxx:xxxx
},
"jest": {
"clearMocks": true,
"resetMocks": true,
"restoreMocks": true
}
}
おまけ
普段はReactを主な領域としているので、出くわすケースをほんの少しだけご紹介します。
- useState
// 配列を保持するStateをMockする場合
const mockSetList = jest.fn()
jest.spyOn(React, 'useState').mockReturnValue([[], mockSetList])
// 基本的にUI描画でAssertionすればよいのですが、UIが変わらない場合もあるので、
// そのときは以下のようにSpyしたFunctionで処理を保証します。
expect(mockSetList).toHaveBeenCalledWith(xxxx)
- LocalStorage
グローバル変数を対象とする場合は以下のようにMock可能です。
const mockLocalStorage = { setItem: jest.fn() };
Object.defineProperty(window, 'localStorage', { value: mockLocalStorage });
// LocalStorageへの処理をMockしつつ、処理結果をSpyすることで動作を保証します。
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(xxx)
最後に
Mockのおかげで、テスト範囲を限定的にでき、本来のテスト項目に集中することができます。Mockをうまくコントロールして、良いテストをかけるように頑張りたいところです。