🐶

【Jest】効率的なテスト作成のためにマニュアルモックを活用する

2023/03/22に公開

こんにちは。株式会社スペースマーケットのwado63です。

テストで関数をモックする際、そのモックが返す値をどのように管理していますか?

以下のようなインターフェイスのReactのhooksがあったとします。
お気に入りの状態と、お気に入りを切り替えた後にその結果を返すという架空のhooksです。

type UseFavorite = (id: string) => {
  isFavorite: boolean;
  toggleFavorite: () => Promise<boolean>;
};

僕はモックが必要になったテストファイルでモックを宣言し、モックの実装も合わせて書いていました。

jest.mock("@/src/hooks/useFavorite", () => ({
  useFavorite: jest.fn().mockReturnValue({
    isFavorite: false,
    toggleFavorite: jest.fn().mockResolveValue(true)
  })
});

このような汎用的なモジュールに依存しているテストを書いていると、こういった記述が何回も出てきます。大変ですね。
モックが返してくるだろうと思われる値をテスト対象のファイルに記載することにも違和感を感じていました。

モックを使う側が値を定義することで、通常は発生しない値を返せてしまいます。

そこで、マニュアルモックを使って効率的に管理できないかと考えました。

マニュアルモックとは

モジュールのディレクトリ直下の__mocks__/ サブディレクトリにモックモジュールを作成することで、モジュールのモックを定義できます。

https://jestjs.io/ja/docs/manual-mocks

マニュアルモックがある状態でモジュールをjest.mock("module")のようにモックするとマニュアルモックに差し変わります。

以下は冒頭にあったuseFavoriteのマニュアルモックを用意した例です。

src/
├── hooks/
│   └── useFavorite/
│       ├── index.ts
│       ├── useFavorite
│       └── __mocks__/
│           └── index.ts
└── components/
    └── RoomCard/
        ├── index.ts
        ├── index.test.ts
        └── RoomCard.tsx

マニュアルモックを以下のように定義しています。

// src/hooks/useFavorite/__mocks__/index.ts

const toggleFavorite = jest.fn();

export const useFavorite = jest.fn().mockReturnValue({
  isFavorite: false,
  toggleFavorite: toggleFavorite.mockResolveValue(true);
});

// マニュアルモックの説明から逸れますが、
// 以下のように書くとtoggleFavoriteが毎回作られてしまうので、あえて分けてます。
// useEffect, useCallbackなどのdependenciesに入るような関数は、テスト時に意図しないような動作をしてしまうので注意が必要です。
//
// export const useFavorite = jest.fn().mockReturnValue({
//   isFavorite: false,
//   toggleFavorite: jest.fn().mockResolveValue(true)
// });

RoomCardコンポーネントは以下のようにuseFavoriteを呼び出します。

// src/components/RoomCard/RoomCard.tsx

import { useFavorite } from "@/src/hooks/useFavorite";

export const RoomCard:FC<{roomId: string}> = ({roomId}) => {
  const { isFavorite, toggleFavorite } = useFavorite(roomId);

  return (<div>
    {/* テンプレート */}
  </div>);
};

RoomCard/index.tsのテストは以下のように書けます。

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

// マニュアルモックを用意してあるのでモックの実装を記述する必要がない
jest.mock("@/src/hooks/useFavorite");

describe("<RoomCard />", () => {
  test('表示できること', () => {
    render(<RoomCard />);

    // expectなどassertion
  });
});

モジュールのパスだけ書けばいいだけなのでかなりスッキリしますね。

ただ、現状のままだとuseFavoriteが返す値によって変わる処理のテストができません。

import { useFavorite } from "@/src/hooks/useFavorite";
jest.mock("@/src/hooks/useFavorite");

describe("<RoomCard />", () => {
  describe("お気に入りされている時", () => {
    beforeEach(() => {
      (useFavorite as jest.Mock).mockReturnValue({
        isFavorite: true,
        toggleFavorite: jest.fn().mockResolveValue(false)
      });
    });

    test('お気に入り状態の表示となること', () => {
      render(<RoomCard />);

      // expectなどassertion
    })
  });
});

このようなことしたら、せっかくのマニュアルモックが台無しです。

マニュアルモックの実装をさらに別なファイルに移動させる

マニュアルモックにモックの実装をまとめつつもテストに応じてモックの動作を変えるにはどうするか。
マニュアルモックの実装をさらに別ファイルに移し、モックの動作を変えるhelper関数を用意します。

// src/hooks/useFavorite/__mocks__/mocks.ts

const toggleFavorite = jest.fn()

export const useFavorite = jest.fn().mockReturnValue({
  isFavorite: false,
  toggleFavorite: toggleFavorite.mockResolvedValue(true),
});

// モックの動作を変えるためのhelper関数
export const setupMockUseFavorite = (
  isFavorite: boolean,
) => {
  useFavorite.mockReturnValue({
    isFavorite,
    toggleFavorite: toggleFavorite.mockResolvedValue(!isFavorite),
  })
}
// src/hooks/useFavorite/__mocks__/index.ts

export * from "./mocks"

こうしておけば、テストではモックの動作を簡単に変更できます。

import { render } from "@testing-library/react";
import { setupMockUseFavorite } from "@/src/hooks/useFavorite/__mocks__";
import { RoomCard } from ".";

jest.mock("@/src/hooks/useFavorite");

beforeEach(() => {
  // モックの返す値をリセットする
  setupMockUseFavorite(false)
})

describe("<RoomCard />", () => {
  describe("お気に入りされている時", () => {
    beforeEach(() => {
      setupMockUseFavorite(true)
    });

    test('お気に入り状態の表示となること', () => {
      render(<RoomCard />);

      // expectなどassertion
    })
  });
});

__mocks__/mocksにマニュアルモックの実体とhelper関数を定義しておくことでテストファイルではモックする関数のことを知らなくて済みますし、モックの実装を使い回すことができます。

このマニュアルモックの対象となるファイルに直接モックの実装をするのではなく、別のファイルに分けるというのがポイントです。
src/hooks/useFavorite/__mocks__/index.tsに直接モックの実装をした場合、setupMockUseFavoriteは動作しません。

マニュアルモックが適用される時に参照が変わってしまうようで、関数を同じ参照先にするため別のファイルに分けています。

今回はモックの動作を変えるhelper関数を用意しましたが、あくまでも一例なので必要に応じてexportする内容を変えると良いと思います。

たとえばですとtoggleFavoriteが呼ばれたかどうかのassertionが簡単にできるようtoggleFavoriteをexportしてもいいですし、helper関数ではなくモックのmockImplementationを動作のパターン分だけ作ってをexportするのもありでしょう。

よく使うモジュールであれば、testのsetupでmockを呼び出しておいてもいいですね。

あとこのマニュアルモックの実装は少し手間がかかるので、汎用的に使うようなモジュールに対してのみ用意するといいと思います。

失敗談

マニュアルモックを作っていると以下のようなwarningに出くわします。

jest-haste-map: duplicate manual mock found: index
  The following files share their name; please delete one of them:
      * <rootDir>/src/...略.../hoge/index.tsx
      * <rootDir>/src/...略.../fuga/index.tsx

ファイルは別なのですが、同じ名前のモックがあるというwarningです。
index.tsxというファイル名が被っただけでwarningが出てしまうのはだいぶ困りますね。

実はこれにはissueが立っています。
https://github.com/facebook/jest/issues/2070

issueは解決されていませんが、暫定対応として
jest.config.jsに以下のように設定するとwarningが出なくなります。

"modulePathIgnorePatterns": ["<rootDir>/src/.*/__mocks__"],

これを誤って

"modulePathIgnorePatterns": ["__mocks__"],

のようにしていると、node_modulesのマニュアルモックが動作しなくなりますので注意が必要です。

以前waningを抑えるためだけにmodulePathIgnorePatternsを設定したようで、
node_modulesのマニュアルをいざ書こうとした時になぜか動作しなくてハマりました。

まとめ

  • 汎用的な関数にはマニュアルモック(__mocks__)を使って、モックの実装をまとめる。
  • 参照が切れないように、モックの実装をマニュアルモックに使われるファイルから分ける。
スペースマーケット Engineer Blog

Discussion