1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-06-09

背景

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

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

前提

  • Node

  • npm

  • Jest基礎知識

準備

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

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

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

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

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

module.exports = { add, multiply };
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 };
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です。

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

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

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

テストが通ればOKです。

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()が利用されます。

例)

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する場合

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する場合

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

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

例)

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には、clearresetrestoreという文言が初期化という文脈で登場し、それぞれが異なる動きをするので説明します。

  • clear

ここに記載のプロパティに関して、初期化が行われます。分かりやすいケースでいうと、コールされた履歴がクリアされます。

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

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

例)

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

ここに記載のように、clear処理 + 独自で設定したMock戻り値やMock処理が初期化されます。

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

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

例)

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

ここに記載のように、jest.spyOn()jest.replaceProperty()でMockした処理を復元します。

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

例)

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処理が自動で行われます。

package.json
{
 "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をうまくコントロールして、良いテストをかけるように頑張りたいところです。

参考

Jest公式ドキュメント

Understanding Jest mocks

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?