Jest Mockのコレを知っとけば何とかなりそう

## 背景

雰囲気で`jest.spyOn()`や`jest.mock()`を使っていたところ、テスト間でMockが不本意に共有されてしまい、ある順番では通るけど、順番を入れ替えるとエラーを吐いてしまうという状況に出くわしました。（原因はMockしたモジュールを復元できていなかったことによるものでした）

これを機に、Mockの種類や初期化方法についてまとめてみようと思います。


## 前提

- Node

- npm

- Jest基礎知識


## 準備
  
```bash:Command
# 作業フォルダに移動後、以下のコマンドを実行してください。
$ mkdir jest-practice
$ cd jest-practice
$ npm init -y
$ npm install -D jest

$ touch utils.js main.js main.test.js
```

各ファイルを以下のように修正します。

```js:utils.js
function add(a, b) {
  return a + b;
}

function multiply(a, b) {
  return a * b;
}

module.exports = { add, multiply };
```

```js:main.js
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 };
```

```js:main.test.js
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です。

```json:package.json
  "scripts": {
    "test": "jest --watchAll"
  }
```

最終的な構成は以下のようになります。

```bash:Directory Tree
$ tree -L 1
.
├── main.js
├── main.test.js
├── node_modules
├── package-lock.json
├── package.json
└── utils.js
```

テストが通ればOKです。

```bash:npm test
$ 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することも可能です。

ただし、以下の点で注意が必要です。

1. **割り当て後にオリジナルの処理を復元できないこと**。
=> テスト間でMockの状態を独立して管理したいときに対応できません。
2. 仮にモジュール全体をMockしたい場合は、モジュールエクスポートされているFunction全てに対して、**個別に割り当てる必要があること**。
=> 現実的ではないので、モジュール全体をMockしたいときは、jest.mock()が利用されます。

例）

```js: main.test.js
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してくれます。

ただし、以下の点で注意が必要です。

1. オリジナル処理の復元が難しいこと
=> `jest.fn()`と同様にテスト間の独立性を保つときに、対応できません。
※`jest.requireActual()`を用いることで状況によっては復元できますが、該当するケースは少ないと思います。

例）完全にモジュール全体をMockする場合

```js: main.test.js
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する場合

```js: main.test.js
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に対するコール情報を観察したいときに有用です。また、上記の２つの方法とは違い、**オリジナルの処理を復元することが可能**なため、テスト間でMockに関する独立性を担保できます。

個人的には、Mockする際にjest.spyOnを最優先で考え、あとはケースに応じて、jest.mock()やjest.fn()を使用しています。

例）

```js: main.test.js
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

[ここ](https://jestjs.io/docs/jest-object#jestclearallmocks)に記載のプロパティに関して、初期化が行われます。分かりやすいケースでいうと、コールされた履歴がクリアされます。

ただし、**Mockに関する一部のプロパティが初期化されるだけ**で、MockしたモジュールやFunctionが**復元されるわけではない**ので注意してください。

Mock Functionを個別でclearしたい場合は、`mockFn.mockClear()`、すべてのMockをclearしたい場合は、`jest.clearAllMocks()`を使用します。

例）

```js:main.test.js
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

[ここ](https://jestjs.io/docs/jest-object#jestresetallmocks)に記載のように、clear処理 + 独自で設定したMock戻り値やMock処理が初期化されます。

ただしこちらも、**Mockに関する一部のプロパティが初期化されるだけ**で、MockしたモジュールやFunctionが**復元されるわけではない**ので注意してください。

Mock Functionを個別でresetしたい場合は、`mockFn.mockReset()`、すべてのMockをresetしたい場合は、`jest.resetAllMocks()`を使用します。

例）

```js:main.test.js
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


[ここ](https://jestjs.io/docs/jest-object#jestrestoreallmocks)に記載のように、`jest.spyOn()`や`jest.replaceProperty()`でMockした処理を復元します。

Mock Functionを個別でrestoreしたい場合は、`mockFn.mockRestore()`、すべてのMockをrestoreしたい場合は、`jest.restoreAllMocks()`を使用します。

例）

```js:main.test.js
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処理が自動で行われます。

```json:package.json
{
 "dependencies": {
     xxxxx:xxxx
 },
 "jest": {
    "clearMocks": true,
    "resetMocks": true,
    "restoreMocks": true
  }
}
```


## おまけ

普段はReactを主な領域としているので、出くわすケースをほんの少しだけご紹介します。

- useState

```js:
// 配列を保持するStateをMockする場合
const mockSetList = jest.fn()
jest.spyOn(React, 'useState').mockReturnValue([[], mockSetList])

// 基本的にUI描画でAssertionすればよいのですが、UIが変わらない場合もあるので、
// そのときは以下のようにSpyしたFunctionで処理を保証します。
expect(mockSetList).toHaveBeenCalledWith(xxxx)
```

- LocalStorage

グローバル変数を対象とする場合は以下のようにMock可能です。

```js:
const mockLocalStorage = { setItem: jest.fn() };
Object.defineProperty(window, 'localStorage', { value: mockLocalStorage });

// LocalStorageへの処理をMockしつつ、処理結果をSpyすることで動作を保証します。
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(xxx)
```

## 最後に

Mockのおかげで、テスト範囲を限定的にでき、本来のテスト項目に集中することができます。Mockをうまくコントロールして、良いテストをかけるように頑張りたいところです。

## 参考
[Jest公式ドキュメント](https://jestjs.io/docs/getting-started)

[Understanding Jest mocks](https://medium.com/@rickhanlonii/understanding-jest-mocks-f0046c68e53c)