Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

Jest モック方法まとめ

追記: 本記事は誤っています

本記事は誤っています。 実際の jest ランタイムの解説として不適切な箇所があります。jest ランタイム難しい…

時間があるときに直そうと思ってるんですが時間が取れません… とりあえず本記事を参考にしないでください。

本文

毎回どれを使えばいいのかわからなくなるのでまとめました。

今回紹介したサンプルはこちらのリポジトリにまとめてあります。 jest-mock-demo

(この記事では純粋に jest について書きたいので、サンプルは TS ではなく JS で書かれています。ts-jest が絡んでも基本的には同じです)

jest のモックは3種類ある

モックの対象によって3パターンあります。

  • モックされた関数(jest.fn())を使う
  • オブジェクトのメソッドをモックする
  • モジュールをモックする

以下でそれぞれ解説します。特にモジュールのモックは複雑なので長めです。

1. モック関数を使う

まずは一番カンタンなモック関数についてです。

const f = jest.fn();
[1, 2, 3].map(f);
expect(f).toBeCalledTimes(3);

モック関数は jest.fn() で生成できます。引数として、あるいはコンポーネントへの prop として渡す場合などに使えます。

呼び出した時に何かして欲しい場合は、 jest.fn().mockImplementation(() => {}) という形で実装します。

const f = jest.fn().mockImplementation((n) => n * 2);
expect([1, 2, 3].map(f)).toEqual([2, 4, 6])
expect(f).toBeCalledTimes(3);

呼び出し回数をリセットするには

モック関数の呼び出し回数は自動でリセットされません。つまり何もしないとテストケースごとに共有されます。

呼び出し回数をリセットするのは簡単です。モック関数の mockClear メソッドを呼び出せば OKです。jest.clearAllMocks() を使えば全てのモック関数を一括で mockClear できます。

const f = jest.fn();
f();
f.mockClear();
expect(f).not.toBeCalled(); // 呼び出し回数がリセットされて0になっている
afterEach(() => {
  jest.clearAllMocks(); // 全てのモック関数の呼び出し回数をリセットする
});

2. オブジェクトのメソッドをモックする

次はオブジェクトのメソッドをモックするケースです。やり方が2 通りあります。

  • jest.spyOn(object, 'method')
  • object.method = jest.fn()

前者はメソッドの上書きが起きません。つまり、以下のコードはログに called と表示されます。メソッドが呼ばれたかをテストしたいときに使えます。

const obj = {
  method: () => {
    console.log('called');
  }
};
const spy = jest.spyOn(obj, 'method');
obj.method(); // ちゃんとログに called が表示される
expect(spy).toBeCalled();

spyOn した上で実装を変えたい場合、mockImplementation を使うか jest.fn() を代入すれば良いです。

const spy = jest.spyOn(obj, 'method').mockImplementation(() => {}); // 実装を上書きする
obj.method(); // ログに called と表示されない
obj.method = jest.fn();
obj.method(); // ログに called と表示されない

3. モジュールをモックする

コレが一番複雑です。jest のランタイムの仕組みと密接に関わっています。

モジュールのモックは、import 先を丸ごとモックするものです。axios などの外部ライブラリをモックする際に便利です。

例: 外部ライブラリをモックする

// fetchArticle.js
import axios from 'axios';

export const fetchArticle = (id) => {
  return axios.get(`/api/article/${id}`)
}
// fetchArticle.test.js

// axios をモックしてみる
jest.mock('axios', () => {
  return {
    default: {
      get: jest.fn().mockImplementation(() => Promise.resolve({body: {result: {title: 'test article', content: 'test article'}}}));
    }
  }
});

// 確認する
import { fetchArticle } fom './fetchArticle';
test('fetchArticle', async () => {
  expect((await fetchArticle()).body.result).toEqual({title:'test article', content: 'test article'}); // fetchArticle 内の axios をモックできている
});

モジュールのモックとモジュールキャッシュについて

モジュールのモックの仕組みは非常にややこしいので、一から説明していきます。

まず、jest の require は返す内容をキャッシュします。つまり、require(module) で帰ってくるものは毎回同一です。

expect(require('axios')).toBe(require('axios')); // コレが成り立つ

孫モジュールもキャッシュされます。

// A.js
export { B } from './B';

// B.js
export const B = () => {};

// A.test.js
expect(require('./A').B).toBe(require('./A').B); // A が依存している B に対してもキャッシュが存在する

このキャッシュのせいでモックが複雑になっています。なんと、モジュールキャッシュがあるとモックが効きません

const oldB = require('./A').B;
jest.doMock('./B', () => ({ B: jest.fn() }));
expect(require('./A').B).toBe(oldB); // キャッシュがあるので require しても oldB が返ってくる = モックできていない

つまり、モックする場合はモジュールのキャッシュを一度リセットする必要があります。

const oldB = require('./A').B;
jest.resetModules(); // モジュールキャッシュを全てクリアする
jest.doMock('./B', () => ({ B: jest.fn() }));

expect(require('./A').B).not.toBe(oldB); // キャッシュが消えているのでモックできている

ただ、ややこしいことにキャッシュを消さなくても良いケースがあります。

  • babel-jest を使っていて jest.mock した場合
  • まだ require しておらずキャッシュが存在しない場合

babel-jest を使っていて jest.mock した場合

babel-jest を使っている場合、jest.mock の呼び出しは自動的にコードブロックの先頭で行われます。つまり、 import や require より先に jest.mock されます

import { B } from './B';
jest.mock('./B', () => ({ B: 42 })); // import より前(=キャッシュがない状態)で実行される

expect(B).toBe(42); // ちゃんとモックされている

この場合はキャッシュがない状態で jest.mock している扱いになるため、require するとちゃんとモックが返ってきます。

まだ require しておらずキャッシュが存在しない場合

mock 実行時点でそのモジュールを一度も require していなければ、mock したものが得られます。

jest.mock('./B', () => ({ B: 42 }));
expect(require('./B').B).toBe(42); // モックされている

ただし、間接的にでも require されてる場合はダメです。

require('./A'); // 中で ./B を require している
jest.doMock('./B', () => ({ B: 42 }));
expect(require('./B').B).not.toBe(42); // ./B のキャッシュがあるのでモックされていない

モジュールが依存しているモジュールをモックしたい

以上を踏まえて、テストしたいモジュールAがモジュールBを使っていて B をモックする場合を考えます。例えば、React コンポーネントが axios に依存していて、axios をモックしたいケースが該当します。

// Article.jsx
import axios from 'axios'; // Article が依存している axios をモックしたい
import React, { useState, useEffect } from 'react';
export const Article = ({ id }) => {
  const [article, setArticle] = useState(null);
  useEffect(() => {
    axios.get(id).then((res) => setArticle(res.body.result));
  }, []);
  return (
    article && (
      <main>
        <h1>{article.title}</h1>
        <p>{article.content}</p>
      </main>
    )
  );
};

すべてのテストケースで同じモックを使う場合、jest.mock で OK です(babel-jest を使っていて jest.mock した場合に該当)。

import { Article } from './Article';
import { render } from '@testing-library/react';
import React from 'react';
jest.mock('axios', () => ({
  get: jest.fn().mockImplementation(() =>
    Promise.resolve({
      body: { result: { title: 'test article', content: 'test article' } }
    })
  )
}));
test('記事が表示できるか', async () => {
  const { findByRole } = render(<Article id="test" />); // モック結果が使われる
  await findByRole('main');
});

テストケースごとに切り替えたい場合、jest.isolateModules か jest.resetModules を使います。

// テストケースごとにモックを切り替えたい
jest.isolateModules(() => {
  const { render } = require('@testing-library/react'); // 再度 import する必要がある
  jest.doMock('axios', () => ({
    get: jest.fn().mockResolvedValue({
      body: { result: { title: 'test article1', content: 'test article' } }
    })
  }));
  const { Article } = require('./Article');
  test('記事A', async () => {
    const { findByRole } = render(<Article id="test" />); // モック結果が使われる
    await findByRole('main');
  });
});
jest.isolateModules(() => {
  const { render } = require('@testing-library/react'); // 再度 import する必要がある
  jest.doMock('axios', () => ({
    get: jest.fn().mockResolvedValue({
      body: { result: { title: 'test article2', content: 'test article' } }
    })
  }));
  const { Article } = require('./Article');
  test('記事B', async () => {
    const { findByRole } = render(<Article id="test" />); // モック結果が使われる
    await findByRole('main');
  });
});

isolateModules の中で再度 React や react-testing-library を import する必要があります。これは、キャッシュが消えているため、Article.jsx のなかで import している React とテストファイルの React が異なる実体を参照しているためです。再 import しないと複数の React インスタンスがあるというエラーが出ます。

まとめ

これまで見てきたように、jest のモックには3種類あります。特にモジュールのモックが非常に複雑で厄介です。

  • モックされた関数(jest.fn())を使う
  • オブジェクトのメソッドをモックする
  • モジュールをモックする

モジュールのモックは jest のモジュールキャッシュの仕組みと、babel-jest の仕組みを理解できればそこまで難しくありません。

今回紹介したサンプルはすべてこちらのリポジトリにまとめてあります。 jest-mock-demo