追記: 本記事は誤っています
本記事は誤っています。 実際の 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();
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();
expect(spy).toBeCalled();
spyOn した上で実装を変えたい場合、mockImplementation を使うか jest.fn() を代入すれば良いです。
const spy = jest.spyOn(obj, 'method').mockImplementation(() => {});
obj.method();
obj.method = jest.fn();
obj.method();
3. モジュールをモックする
コレが一番複雑です。jest のランタイムの仕組みと密接に関わっています。
モジュールのモックは、import 先を丸ごとモックするものです。axios などの外部ライブラリをモックする際に便利です。
例: 外部ライブラリをモックする
import axios from 'axios';
export const fetchArticle = (id) => {
return axios.get(`/api/article/${id}`)
}
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'});
});
モジュールのモックとモジュールキャッシュについて
モジュールのモックの仕組みは非常にややこしいので、一から説明していきます。
まず、jest の require は返す内容をキャッシュします。つまり、require(module)
で帰ってくるものは毎回同一です。
expect(require('axios')).toBe(require('axios'));
孫モジュールもキャッシュされます。
export { B } from './B';
export const B = () => {};
expect(require('./A').B).toBe(require('./A').B);
このキャッシュのせいでモックが複雑になっています。なんと、モジュールキャッシュがあるとモックが効きません 。
const oldB = require('./A').B;
jest.doMock('./B', () => ({ B: jest.fn() }));
expect(require('./A').B).toBe(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 }));
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');
jest.doMock('./B', () => ({ B: 42 }));
expect(require('./B').B).not.toBe(42);
モジュールが依存しているモジュールをモックしたい
以上を踏まえて、テストしたいモジュールAがモジュールBを使っていて B をモックする場合を考えます。例えば、React コンポーネントが axios に依存していて、axios をモックしたいケースが該当します。
import axios from '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');
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');
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