🃏

わかる!Jest モック

2023/08/23に公開

jestの名前の由来は Jest(JavaScript Test) だが、jest の靴のロゴの由来は jest が jester (ピエロ) に響きが似ているからという理由だそうです。

さて、jestのモックは jest.fn(), jest.mock(), jest.spyOnの3つを理解すればマスターすることができます。この記事を読めばどんなものでもモックすることができるようになるでしょう。

jest.fn()

jest.fn() はモックオブジェクトを作成します。 引数なしでモック関数は実行時に undefined を返します。("no-op"(no operation)の関数 function (){} が実行されるのと同じ)
モックは何回呼ばれたか、どんな引数と一緒に実行されたかなどの情報をモックオブジェクトの中に持っています。

it('should return undefined by default', () => {
  const mock = jest.fn(); // 引数に何も指定しないと実行時 undefined
  expect(mock('foo')).toBeUndefined(); // PASS!
  expcet(mock).toHaveBeenCalledTimes(1); // PASS!
  expect(mock).toHaveBeenCalledWith('foo'); // PASS!
});

次に、jest.fn()に 文字列 barを返す関数を渡しています。実行するとbarを返します。mockImplementation()mockReturnValueでも同じように定義することができます。

it('should return specified value', () => {
  const mock = jest.fn(() => 'bar');
  // or jest.fn().mockImplementation(() => 'bar');
  // or jest.fn().mockReturnValue('bar');
  expect(mock()).toBe('bar') // PASS!
});

jest.mock() でモジュールをモックする

jest.mock()はモジュール全体をモックします。
モジュールをモックする場合は、jest.mock('axios') のようにモジュール名を指定します。

jest.mock('axios') を実行したときに内部でどのようなことが起きているかというと、axios に存在する全てのメソッドが jest.fn()に置き換わります。非常に強力なこの方法は 自動モック と呼ばれています。

後半では axios.getに任意のモックを定義しています。

import axios from 'axios';

jest.mock('axios');
// 内部では axios の全てのメソッドに `jest.fn()` がセットされる!
// axios.get = jest.fn();
// axios.post = jest.fn();
// axios.put = jest.fn();
// axios.delete = jest.fn();
// ...

it('shoudl mock axios', () => {
  expect(axios.get('/foo')).toBeUndefined(); // PASS!
  expect(axios.post('/foo')).toBeUndefined(); // PASS!
  expect(axios.put('/foo')).toBeUndefined(); // PASS!
  expect(axios.delete('/foo')).toBeUndefined(); // PASS!

  // axios.get に任意のデータを返すようにモックする
  (axios as jest.Mocked<typeof axios>).get.mockResolvedValue({ data: "baz" })
  // or (axios as jest.Mocked<typeof axios>).get.mockImplementation(
  //  () => Promise.resolved({ data: "baz" }));
 
  expect(axios.get("/bar")).resolves.toEqual({ data: "baz" }); // PASS!
});

jest.mock() で独自モジュールをモックする

jest.mock() は独自のモジュールもモックすることができます。

以下のような独自で作成した math.js モジュールがあるとします。このモジュールをモックします。

math.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => (b === 0 ? null : a / b); 

独自のモジュールをモックする場合は、モジュールのパスを指定します。
先述したaxiosの例と同様に mathのメソッドが全て jest.fn()に置き換わります。

math.test.js
import * as math from './math'

jest.mock('./math'); // モジュールのパスを指定する
// 全てのメソッドに `jest.fn()` がセットされる!
// math.add = jest.fn();
// math.subtract = jest.fn();
// math.multiply = jest.fn();
// math.divide = jest.fn();

jest.mock() でモジュールの一部だけモックする

今度は独自モジュールの一部をモックします。
先ほどと同じようにパス名を指定しています。ここでは method3だけモックし、任意の文字列を返しています。
jest.requireActual('./utils')をマージすることで、defaultMethod, method1, method2 は実際の実装が使われます。このように、モジュールの一部だけモックし、他は実際の実装とすることが可能です。

デフォルトエクスポートがある場合は、__esModule: true が必要になります。

import defaultMethod, { method1, method2, method3 } from "./utils";

jest.mock("./utils", () => ({
  __esModule: true, // default export がある場合は必須
  ...jest.requireActual("./utils"),
  method3: jest
    .fn()
    .mockImplementation(() => "You have called a mocked method 3!"),
}));

jest.spyOn() で axios をモックする

jest.spyOn は既存のオブジェクトをモックします。
jest.mock()と似ていますが、jest.spyOn() の方が局所的にモックでき、オリジナルの実装に戻すのが容易なので使い勝手がいいです。

先ほどは jest.mock() を使用して axiosをモックしました。jest.mock()の自動モックは強力ですが、唯一の欠点はモジュールの元の実装にアクセスするのが難しいことです。そのような場合、jest.spyOn()が使えます。

jest.spyOn()を使ってaxiosをモックします。spyOn() にモジュール名、メソッド名を指定します。

今回はモック作成時に mockImplementationOnce() を使用していることに注目してください。
このメソッドは1回だけモックを返し、2回目はモックの結果が返っていないことがわかります。

※ 2回目以降は axios はオリジナルの実装に戻ってはいますが、実際のHTTPリクエストを行なっていません。axiosはテスト内で非同期リクエストをシミュレートし、即座にresolvesし、空のオブジェクトを返すことになります。

import axios from "axios";

const mockApiSuccess = () => {
  const users = [{ name: "Bob" }];
  const response = { data: users };
  return jest
    .spyOn(axios, 'get')
    .mockImplementationOnce(() => Promise.resolve(response));
}

it('should mock axios once', () => {
  mockApiSuccess();
  
  // 1回目 モックが呼ばれる
  expect(axios.get("/users/12345")).resolves.toEqual({
    data: [{ name: "Bob" }],
  });  // PASS!
  // 2回目
  expect(axios.get("/users/12345")).resolves.toEqual({}); // PASS!
})

jest.spyOn() で既存のオブジェクトをモックする 1

Math.random()をモックします。Math.random()は毎回ランダムな値を返しますが、ここでは固定値を返すようにモックしています。

it('should mock Math.random', () => {
  jest.spyOn(Math, 'random').mockReturnValue(0.5);
  
  expect(Math.random()).toBe(0.5);
})

jest.spyOn() で既存のオブジェクトをモックする 2

続いて、window オブジェクトの location をモックする場面もよくあります。

jest.spyOn()の第3引数は accessTypeで、getsetかを指定します。

ここでは、 変数spy に spyオブジェクトを代入しています。
1回目の window.location.href ではモックした値が返り、その後 spy.mockRestore() をすることで元の実装に戻しています。afrerEachなどで、テスト毎にモックをリセットするのがおすすめです。

2回目の window.location.hrefjsdom (仮装ブラウザ)環境の window.location.hrefの値が返っています。

it('should mock window.location', () => {
  const spy = jest.spyOn(window, 'location', 'get').mockReutrnValue({
    ...window.location,
    href: 'https://zenn.dev/'
  });
  
  expect(window.location.href).toBe('https://zenn.dev/');
  
  spy.mockRestore(); // モックを元の実装に戻す
  
  expect(window.location.href).toBe('http://localhost/');
});

jest.spyOn() が使えない場合

前述している通り、jest.spyOn() は既存のオブジェクトをモックします。つまり、存在しないものはモックできません。あるケースとして、window.matchMedia は jsdom に存在しないため、モックすることができません。そういう場合はどうするのかというと、Object.definePropertywindow.matchMedia を定義します。

https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function

Object.defineProperty(window, 'matchMedia', {
  value: jest.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(), // Deprecated
    removeListener: jest.fn(), // Deprecated
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
  writable: true,
})

以上、Jest モックの使い方について紹介しました。

最後に、Sandi Metz さんの有名な一節を紹介して締めたいと思います。

「テストにコストがかかることの解決方法は、テストをやめることではありません。うまくなることです。」 -- Sandi Metz 『オブジェクト指向設計実践入門』

Discussion