🕯️

Jestのテストで一部だけモックしたい場合

2024/09/27に公開

🔖 概要

Jestを使ってモック化するときに一部をモックしたい場合に手こずったので経過と共にメモ。
※ React(Next.js)&TypeScriptを使用

【🏃 お急ぎ便 🏃】
やりたいことは 準備テスト項目に、自分なりの答えは 結論 に記載。

🌏 環境

  • React: 18
  • Next.js: 13.5.6
  • Jest: 29.7.0

📦 準備

以下2つのコンポーネント ButtonGroup.tsxButton.tsx を使用。
ButtonGroupのテストをするために子コンポーネントであるButtonをモック化したりしなかったりする。

./components/ButtonGroup/ButtonGroup.tsx
import { memo } from "react";
import { Button } from "@/components";

const clickButton = () => {
  console.log("clicked");
};

const ButtonGroup = () => {
  return (
    <div
      style={{
        width: "100%",
        display: "flex",
        padding: "16px 40px",
      }}
    >
      <Button text="ボタン1つ目" onClick={clickButton} />
      <Button text="ボタン2つ目" onClick={() => {}} disabled />
    </div>
  );
};

export default memo(ButtonGroup);
./components/Button/Button.tsx
import { FC, memo } from "react";

type Props = {
  text: string;
  onClick: () => void;
  disabled?: boolean;
};

const Button: FC<Props> = (props) => {
  const { text, onClick, disabled } = props;

  return (
    <button
      style={{ width: "120px", fontSize: "16px" }}
      onClick={onClick}
      disabled={disabled}
    >
      {text}
    </button>
  );
};

export default memo(Button);

ちなみにファイル構成はこんな感じの想定。
※ 複数ファイルで管理するとファイルを探すのが面倒なので1つのファイルで取り扱う

components
  ├ Button
  │  └ Button.tsx
  └ ButtonGroup
      ├ ButtonGroup.tsx
      └ BUttonGroup.test.tsx ← 今回はこのファイル

🏁 テスト項目

今回は何か処理に異常があった際に原因の切り分けがしやすいよう

  • Button をモック化して呼び出せているか
  • Button をモック化せずレンダリングし、要素が存在するか
    の2点に重点をおいて書いてみる

🖋️ テスト - まずは書いてみる

Button をモック化して呼び出せているか

Jest公式サイト の書き方を参考に jest.mock() を用いてButtonをモック化してみる

テストを書いてみる
// Buttonコンポーネントをモック化
jest.mock("../Button/Button", () => {
  return {
    __esModule: true,
    default: jest.fn(),
  };
});

import { render } from "@testing-library/react";
import Button from "../Button/Button";
import ButtonGroup from "./ButtonGroup";

test("Buttonを呼び出せているか確認するテスト", () => {
  render(<ButtonGroup />);
  expect(Button).toHaveBeenCalledTimes(2);
});

ちなみに、モックのタイミングでreturnをせず jest.mock('../Button/Button') だけ指定すると下記のエラーになってしまう

● Buttonを呼び出せているか確認するテスト

    expect(received).toHaveBeenCalledTimes(expected)
    Matcher error: received value must be a mock or spy function

    Received has type:  object
    Received has value: {"$$typeof": Symbol(react.memo), "compare": null, "type": [Function Button]}

Button をモック化せずレンダリングし、要素が存在するか

先ほどのテストと「実際のButtonコンポーネントがレンダリングされているか」を確認するテストを1つのテストファイルに書こうとする。
さっきモック化したのでモック化したものを解除(unmock)した後にテストすればいいということで以下のように書いてみる。

// Buttonコンポーネントをモック化
jest.mock("../Button/Button", () => {
  return {
    __esModule: true,
    default: jest.fn(),
  };
});

import { render, screen } from "@testing-library/react";
import Button from "../Button/Button";
import ButtonGroup from "./ButtonGroup";

test("Buttonを呼び出せているか確認するテスト", () => {
  render(<ButtonGroup />);
  expect(Button).toHaveBeenCalledTimes(2);
});

jest.unmock("../Button/Button");

test("ちゃんとレンダリングされていることを確認するテスト", () => {
  render(<ButtonGroup />);

  // レンダリング結果を確認
  const button = screen.getByText("ボタン1つ目");
  expect(button).toBeInTheDocument();
});

と先述のエラーが発生してしまう
console.log(debug) を以下のように仕込んでみると

// Buttonコンポーネントをモック化
jest.mock("../Button/Button", () => {
  return {
    __esModule: true,
    default: jest.fn(),
  };
});

import { render, screen } from "@testing-library/react";
import Button from "../Button/Button";
import ButtonGroup from "./ButtonGroup";

test("Buttonを呼び出せているか確認するテスト", () => {
  console.debug("testの中");
  render(<ButtonGroup />);
  expect(Button).toHaveBeenCalledTimes(2);
});

console.debug("unmockの直前");
jest.unmock("../Button/Button");

test("ちゃんとレンダリングされていることを確認するテスト", () => {
  render(<ButtonGroup />);

  // レンダリング結果を確認
  const button = screen.getByText("ボタン1つ目");
  expect(button).toBeInTheDocument();
});
  ● Console
    console.debug
      unmockの直前

    console.debug
      testの中

ということで、どうやら mock→unmock→testで実行されているみたい🤔
じゃあ特定のテストの中でjest.mock()すればいいじゃんということで

import React from "react";
import { render, screen } from "@testing-library/react";
import Button from "../Button/Button";
import ButtonGroup from "./ButtonGroup";

test("Buttonを呼び出せているか確認するテスト", () => {
  // Buttonコンポーネントをモック化
  jest.mock("../Button/Button", () => {
    return {
      __esModule: true,
      default: jest.fn(),
    };
  });
  render(<ButtonGroup />);
  expect(Button).toHaveBeenCalledTimes(2);
  jest.unmock("../Button/Button");
});

test("ちゃんとレンダリングされていることを確認するテスト", () => {
  render(<ButtonGroup />);

  // レンダリング結果を確認
  const button = screen.getByText("ボタン1つ目");
  expect(button).toBeInTheDocument();
});

としたがエラーが消えず...
これについてはここ

Jestは自動的にjest.mockコールを自動的にモジュールの先頭に(importを行う前に)移動します。

とあるのでtestの中では使えず、原則ファイルの直下で記載しないといけなさそう...
これはjest.unmock()も同じみたいで、jest.unmock()だけテストの中に移動しても意味がなさそう。

というわけで、他のモック解除の方法を探してみる。

🫥 テスト - 2つ目でモックを解除してみる

resetAllMocks()restoreAllMocks() を用いてテストの度にモックをリセットすることを試みる。

// Buttonコンポーネントをモック化
jest.mock("../Button/Button", () => {
  return {
    __esModule: true,
    default: jest.fn(),
  };
});

import React from "react";
import { render, screen } from "@testing-library/react";
import Button from "../Button/Button";
import ButtonGroup from "./ButtonGroup";

beforeEach(() => {
  jest.resetAllMocks();
  jest.restoreAllMocks();
});

test("Buttonを呼び出せているか確認するテスト", () => {
  render(<ButtonGroup />);
  expect(Button).toHaveBeenCalledTimes(2);
});

test("ちゃんとレンダリングされていることを確認するテスト", () => {
  render(<ButtonGroup />);

  // レンダリング結果を確認
  const button = screen.getByText("ボタン1つ目");
  expect(button).toBeInTheDocument();
});

が、2つ目のテストでもモック化された状態でエラーが発生してしまう。
調べてみると jest.resetAlMocks()

Resets the state of all mocks. Equivalent to calling .mockReset() on every mocked function.

mockFn.mockReset()

Does everything that mockFn.mockClear() does, and also replaces the mock implementation with an empty function, returning undefined.

と書いてあるので、全てのモックに対して空を返すようにしてくれる。
その上で jest.restoreAllMocks()

Beware that jest.restoreAllMocks() only works for mocks created with jest.spyOn() and properties replaced with jest.replaceProperty();

に記載の通り jest.spyOn() や jest.replaceProperty() で設定したものじゃないとrestoreできないらしい。
spyOn()はオブジェクト内の一部メソッドをモック化する役割を果たすので今回使うのは断念。

ここまでで、「2つ目のテストでモック解除してみる」ということが難しいそうだったので
1つ目のテストだけでモックできるか」という方針にしてみる...

🧭 テスト - 1つ目のテストだけでモックしてみる

というわけで、テストの中で使用できそうな jest.doMock() を試してみる。
まずは公式の書き方に倣って「Buttonを呼び出せているか確認するテスト」を書いてみる。

import React from "react";
import { render } from "@testing-library/react";

beforeEach(() => {
  jest.resetModules();
});

test("Buttonを呼び出せているか確認するテスト", () => {
  jest.doMock("../Button/Button", () => {
    return {
      __esModule: true,
      default: jest.fn(),
    };
  });

  const ButtonGroup = require("./ButtonGroup").default;
  const Button = require("../Button/Button").default;

  render(<ButtonGroup />);
  expect(Button).toHaveBeenCalledTimes(2);
});

render()するButtonGroupもButtonもrequireでの呼び出しが必要みたいなので追加。
2つ目のテストは特にモック化の処理なども必要ないのでこのように書ける。

import React from "react";
import { render, screen } from "@testing-library/react";
import ButtonGroup from "./ButtonGroup";

beforeEach(() => {
  jest.resetModules();
});

test("Buttonを呼び出せているか確認するテスト", () => {
  jest.doMock("../Button/Button", () => {
    return {
      __esModule: true,
      default: jest.fn(),
    };
  });

  const ButtonGroup = require("./ButtonGroup").default;
  const Button = require("../Button/Button").default;

  render(<ButtonGroup />);
  expect(Button).toHaveBeenCalledTimes(2);
});

test("ちゃんとレンダリングされていることを確認するテスト", () => {
  render(<ButtonGroup />);

  // レンダリング結果を確認
  const button = screen.getByText("ボタン1つ目");
  expect(button).toBeInTheDocument();
});

また、 jest.isolateModules() を使ってjest.mock()を実施する方法もあるっぽい。

import React from "react";
import { render, screen } from "@testing-library/react";
import ButtonGroup from "./ButtonGroup";

test("Buttonを呼び出せているか確認するテスト", () => {
  jest.isolateModules(() => {
    jest.mock("../Button/Button", () => {
      return {
        __esModule: true,
        default: jest.fn(),
      };
    });

    const ButtonGroup = require("./ButtonGroup").default;
    const Button = require("../Button/Button").default;

    render(<ButtonGroup />);
    expect(Button).toHaveBeenCalledTimes(2);
  });
});

test("ちゃんとレンダリングされていることを確認するテスト", () => {
  render(<ButtonGroup />);

  // レンダリング結果を確認
  const button = screen.getByText("ボタン1つ目");
  expect(button).toBeInTheDocument();
});

📢 結論

jest.doMock() を使う方法

import React from "react";
import { render, screen } from "@testing-library/react";
import ButtonGroup from "./ButtonGroup";

beforeEach(() => {
  jest.resetModules();
});

test("Buttonを呼び出せているか確認するテスト", () => {
  jest.doMock("../Button/Button", () => {
    return {
      __esModule: true,
      default: jest.fn(),
    };
  });

  const ButtonGroup = require("./ButtonGroup").default;
  const Button = require("../Button/Button").default;

  render(<ButtonGroup />);
  expect(Button).toHaveBeenCalledTimes(2);
});

test("ちゃんとレンダリングされていることを確認するテスト", () => {
  render(<ButtonGroup />);

  // レンダリング結果を確認
  const button = screen.getByText("ボタン1つ目");
  expect(button).toBeInTheDocument();
});

jest.isolateModules() を使う方法

import React from "react";
import { render, screen } from "@testing-library/react";
import ButtonGroup from "./ButtonGroup";

test("Buttonを呼び出せているか確認するテスト", () => {
  jest.isolateModules(() => {
    jest.mock("../Button/Button", () => {
      return {
        __esModule: true,
        default: jest.fn(),
      };
    });

    const ButtonGroup = require("./ButtonGroup").default;
    const Button = require("../Button/Button").default;

    render(<ButtonGroup />);
    expect(Button).toHaveBeenCalledTimes(2);
  });
});

test("ちゃんとレンダリングされていることを確認するテスト", () => {
  render(<ButtonGroup />);

  // レンダリング結果を確認
  const button = screen.getByText("ボタン1つ目");
  expect(button).toBeInTheDocument();
});

どっちがいいの??

テストの方針や書き方自体はプロジェクトに依存するのでそのプロジェクトに従って採用するのが良いのかなという気持ちだが、
どちらかというと jest.isolateModules() に軍配が上がりそう

5回実行した平均速度は doMock()→約2.188s、isolateModules()→約1.557sだったので
若干 jest.isolateModules() を使った方が早いかも??

doMock() isolateModules()
1回目 2.723 1.400
2回目 2.486 1.475
3回目 1.750 1.405
4回目 1.796 1.875
5回目 2.187 1.628

※ 単位: 秒

ChatGPTは以下の観点から jest.isolateModules() 推奨しているっぽい。

ChatGPTの回答
2つのコードスニペットは、どちらもReactコンポーネントのテストにおけるモック化とレンダリングを扱っていますが、それぞれ異なる方法で行っています。結論から言うと、2番目のテストの方がより良いといえます。以下の観点から理由を詳しく説明します。

### 主な検討観点
1. **モジュールのキャッシュや依存関係のリセット方法**
2. **テストの分離性とモックの扱い**
3. **コードの可読性と意図の明確さ**
4. **テストパフォーマンス**

---

### 1. **モジュールのキャッシュや依存関係のリセット方法**
- **1つ目のテスト(`jest.resetModules()`**  
  - `beforeEach`フックで `jest.resetModules()` を使って、各テスト前にモジュールキャッシュをリセットしています。これにより、テスト実行ごとにモジュールが再読み込みされるため、モジュール間の依存関係がクリアされます。ただし、この方法はテスト全体に影響を与えるため、必要以上にモジュールがリセットされる可能性があります。
- **2つ目のテスト(`jest.isolateModules()`**  
  - `jest.isolateModules()` を使って、テストがモジュールのキャッシュに依存せずに実行されるようにしています。これにより、各テストケースが隔離されて実行され、他のテストケースに影響を与えません。モック化をそのテストのスコープ内でのみ適用できるので、他のテストケースに対する影響が最小限です。

**評価**: `jest.isolateModules()` を使用する2つ目のテストは、テストケースごとの分離性を保ち、モジュールキャッシュの問題を避けるため、より推奨されるアプローチです。

---

### 2. **テストの分離性とモックの扱い**
- **1つ目のテスト**  
  - `jest.doMock()` でボタンコンポーネントをモック化していますが、モジュールのキャッシュが影響するため、リセットされていない他のテストケースに影響を与える可能性があります。`jest.resetModules()`を使ってキャッシュをリセットすることで、この影響を避けていますが、複数のテスト間で不必要なオーバーヘッドが発生します。
- **2つ目のテスト**  
  - `jest.isolateModules()` を使用することで、テストケースごとにモジュールの状態を完全にリセットし、テストが独立して実行されることを保証しています。この方法はモック化が他のテストケースに影響を与えることを防ぐため、より堅牢なテストが可能です。

**評価**: `jest.isolateModules()` を使った2つ目のテストは、モックをテストケース内に限定することで、テストケースの独立性を高めています。

---

### 3. **コードの可読性と意図の明確さ**
- **1つ目のテスト**  
  - `beforeEach``jest.resetModules()` を使うことで、テストの初期化処理が追加されており、テスト全体の流れがやや複雑になります。また、 `jest.doMock()` を使ったモックの処理もやや冗長です。
- **2つ目のテスト**  
  - テストケースごとに `jest.isolateModules()` でモックを処理しており、意図が明確でシンプルです。各テストケースが完全に独立していることもコードから理解しやすく、可読性が高いです。

**評価**: 2つ目のテストは、コードがより簡潔で、モックの意図が明確です。

---

### 4. **テストパフォーマンス**
- **1つ目のテスト**  
  - `jest.resetModules()` を使うと、テストごとに全モジュールのキャッシュがリセットされるため、パフォーマンスに影響する可能性があります。特に依存モジュールが多い場合やテストケースが多い場合には、非効率になることがあります。
- **2つ目のテスト**  
  - `jest.isolateModules()` は必要なモジュールのみをリセットし、モジュールの再読み込みを必要最小限に抑えます。これにより、テストパフォーマンスは最適化されます。

**評価**: 2つ目のテストは、パフォーマンス面でも優れています。

---

### 総合評価

2つ目のテストの方が、以下の理由で優れています。
- **テストの分離性**が保たれており、モジュールキャッシュに依存しない。
- **コードがシンプル**で意図が明確。
- **パフォーマンス**が向上する可能性が高い。

よって、2つ目のアプローチ(`jest.isolateModules()` を使用したもの)を推奨します。
Arsaga Developers Blog

Discussion