【react-router + Jest】useNavigateをJestでモック化できない

# はじめに
useNavigateを使用しているコンポネーントのテストを行う際に、useNavigateのモック化が理解できず、テスト実行時にエラーとなりました。useNavigateのモック化方法をまとめてみました。

# 問題
useNavigateのモック化してテスト実行すると下記のエラーが発生しました。

```shell
● 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に格納して使用していました。

```tsx
const navigate = useNavigate(); // useNavigateで返却された関数をnavigateに格納
export const UserCard = () => {
  return (
    <Button
      w="100%"
      mt={4}
      colorScheme="teal"
      data-testid="remove-button"
      onClick={() => navigate("/")}  // navigateで実行！
    >
      戻る
    </Button>
  );
};
```

実際に書いていたテスト
```tsx
jest.mock("react-router", () => ({
  ...jest.requireActual("react-router"),
  useNavigate: jest.fn(), // これだとuseNavigate()がモック関数になり、使用時はundefinedを返す
}));
```

# 解決方法
モック関数（mockNavigate）を別途作り、useNavigateが呼ばれた際はモック関数を呼ぶようにしました。

```diff_tsx
+const mockNavigate = jest.fn();
jest.mock("react-router", () => ({
  ...jest.requireActual("react-router"),
+  useNavigate: () => mockNavigate,
-   useNavigate: jest.fn(),
}));
```

1. mockNavigate というモック関数を作成
2. `...jest.requireActual("react-router"),`：`jest.requireActual("react-router")`でreact-routerの全てのモジュールをエクスポート、`...`（スプレッド構文）でコピー
3. useNavigate が呼ばれたときに mockNavigate を返すようにする
4. コンポーネントでは const navigate = useNavigate() と書かれており、navigate には mockNavigate が入る
4. navigate("/") が呼ばれると、実際には mockNavigate("/") が実行される

**完成系**

<details><summary>テストコード</summary>

```tsx: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("/");
  });
});
```

</details>

<details><summary>テスト対象のコンポーネント</summary>

```tsx: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>
  );
};

```
</details>

# おわりに
関数が何を返すのかも意識して、テストしないといけないと思いました。
今回エラーに向き合えたことで、他のHooksでモック化する際にも応用ができそうです。

# 参考

https://qiita.com/shiva_it/items/48e92101a31e4a2ba50d

https://jestjs.io/ja/docs/jest-object#jestrequireactualmodulename