【Jest】効率的なテスト作成のためにマニュアルモックを活用する
こんにちは。株式会社スペースマーケットの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__/ サブディレクトリにモックモジュールを作成することで、モジュールのモックを定義できます。
マニュアルモックがある状態でモジュールを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が立っています。
issueは解決されていませんが、暫定対応として
jest.config.jsに以下のように設定するとwarningが出なくなります。
"modulePathIgnorePatterns": ["<rootDir>/src/.*/__mocks__"],
これを誤って
"modulePathIgnorePatterns": ["__mocks__"],
のようにしていると、node_modulesのマニュアルモックが動作しなくなりますので注意が必要です。
以前waningを抑えるためだけにmodulePathIgnorePatterns
を設定したようで、
node_modulesのマニュアルをいざ書こうとした時になぜか動作しなくてハマりました。
まとめ
- 汎用的な関数にはマニュアルモック(
__mocks__
)を使って、モックの実装をまとめる。 - 参照が切れないように、モックの実装をマニュアルモックに使われるファイルから分ける。
スペースを簡単に貸し借りできるサービス「スペースマーケット」のエンジニアによる公式ブログです。 弊社採用技術スタックはこちら -> whatweuse.dev/company/spacemarket
Discussion