はじめに
useNavigateを使用しているコンポネーントのテストを行う際に、useNavigateのモック化が理解できず、テスト実行時にエラーとなりました。useNavigateのモック化方法をまとめてみました。
問題
useNavigateのモック化してテスト実行すると下記のエラーが発生しました。
● UserCard › 戻るボタンをクリックすると/に遷移すること
expect(jest.fn()).toHaveBeenCalledWith(...expected)
Expected: "/"
Received
1: called with 0 arguments
2: called with 0 arguments
Number of calls: 2
84 | await userEvent.click(removeButton);
85 |
> 86 | expect(useNavigate).toHaveBeenCalledWith("/");
| ^
87 | });
88 | });
89 |
at Object.<anonymous> (src/tests/userCardComponent.spec.tsx:86:25)
PASS src/tests/sampleComponent.spec.tsx
Test Suites: 1 failed, 1 passed, 2 total
Tests: 1 failed, 7 passed, 8 total
Snapshots: 0 total
Time: 1.923 s
原因
useNavigate
自体をモック関数にしてしまっていました。
React Router の useNavigate は 関数を返す関数
useNavigate
は関数を返す関数であることを理解できていませんでした。
テスト対象のコンポーネントでは確かに返却された関数をnavigateに格納して使用していました。
const navigate = useNavigate(); // useNavigateで返却された関数をnavigateに格納
export const UserCard = () => {
return (
<Button
w="100%"
mt={4}
colorScheme="teal"
data-testid="remove-button"
onClick={() => navigate("/")} // navigateで実行!
>
戻る
</Button>
);
};
実際に書いていたテスト
jest.mock("react-router", () => ({
...jest.requireActual("react-router"),
useNavigate: jest.fn(), // これだとuseNavigate()がモック関数になり、使用時はundefinedを返す
}));
解決方法
モック関数(mockNavigate)を別途作り、useNavigateが呼ばれた際はモック関数を呼ぶようにしました。
+const mockNavigate = jest.fn();
jest.mock("react-router", () => ({
...jest.requireActual("react-router"),
+ useNavigate: () => mockNavigate,
- useNavigate: jest.fn(),
}));
- mockNavigate というモック関数を作成
-
...jest.requireActual("react-router"),
:jest.requireActual("react-router")
でreact-routerの全てのモジュールをエクスポート、...
(スプレッド構文)でコピー - useNavigate が呼ばれたときに mockNavigate を返すようにする
- コンポーネントでは const navigate = useNavigate() と書かれており、navigate には mockNavigate が入る
- navigate("/") が呼ばれると、実際には mockNavigate("/") が実行される
完成系
テストコード
test.tsx
jest.mock("../utils/supabaseFunctions", () => ({
getUserById: jest.fn(),
}));
const mockNavigate = jest.fn();
jest.mock("react-router", () => ({
...jest.requireActual("react-router"),
useNavigate: () => mockNavigate,
}));
// モックデータを作成
const mockUser = {
id: "apple",
name: "テストユーザー",
description: "<p>テストユーザーの説明</p>",
skill_name: "React",
github_id: "testuser",
qiita_id: "testuser",
x_id: "testuser",
getGithubUrl: jest.fn(() => "https://github.com/testuser"),
getQiitaUrl: jest.fn(() => "https://qiita.com/testuser"),
getXUrl: jest.fn(() => "https://x.com/testuser"),
};
beforeEach(() => {
jest.clearAllMocks();
// DBから取得したデータをモックする
(getUserById as jest.Mock).mockResolvedValue([mockUser]);
});
describe("UserCard", () => {
it("戻るボタンをクリックすると/に遷移すること", async () => {
render(<UserCard />, { wrapper: BrowserRouter });
// 戻るボタンをクリック
const removeButton = await screen.findByTestId("remove-button");
await userEvent.click(removeButton);
expect(mockNavigate).toHaveBeenCalledWith("/");
});
});
テスト対象のコンポーネント
UserCard.tsx
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { getUserById } from "../utils/supabaseFunctions";
import { User } from "../domain/user";
import { Box, Button, Card, CardBody, Heading } from "@chakra-ui/react";
import { IoLogoGithub } from "react-icons/io5";
import { SiQiita } from "react-icons/si";
import { RiTwitterXFill } from "react-icons/ri";
export const UserCard = () => {
const { id } = useParams();
const [loadingFlag, setLoadingFlag] = useState(true);
const [user, setUser] = useState<User[]>();
const navigate = useNavigate();
const getData = async () => {
const data = await getUserById(id!);
setUser(data);
if (data.length !== 0) {
setLoadingFlag(false);
}
};
useEffect(() => {
getData();
}, []);
if (loadingFlag) {
return <h1>loading…</h1>;
}
return (
<Box
height="100vh"
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
bg="#C4F1F9"
>
<Box width="sm" p={4}>
{user?.map((user) => {
return (
<Card key={user.id}>
<CardBody>
<Box>
<Heading as="h1" size="lg" mb={4} data-testid="name">
{user.name}
</Heading>
</Box>
<Box mb={2}>
<Heading as="h2" size="md">
好きな技術
</Heading>
<div data-testid="favorite_skill">{user.skill_name}</div>
</Box>
<Box display="flex" justifyContent="center">
<Box w="33%" textAlign="center">
<a href={user.getGithubUrl(user.github_id)}>
<IoLogoGithub data-testid="github-icon" fontSize="40px" />
</a>
</Box>
<Box w="33%" textAlign="center">
<a href={user.getQiitaUrl(user.qiita_id)}>
<SiQiita data-testid="qiita-icon" fontSize="40px" />
</a>
</Box>
<Box w="33%" textAlign="center">
<a href={user.getXUrl(user.x_id)}>
<RiTwitterXFill data-testid="x-icon" fontSize="40px" />
</a>
</Box>
</Box>
</CardBody>
</Card>
);
})}
</Box>
<Box width="sm" p={4} textAlign="center">
<Button
w="100%"
mt={4}
colorScheme="teal"
data-testid="remove-button"
onClick={() => navigate("/")}
>
戻る
</Button>
</Box>
</Box>
);
};
おわりに
関数が何を返すのかも意識して、テストしないといけないと思いました。
今回エラーに向き合えたことで、他のHooksでモック化する際にも応用ができそうです。
参考