Mocking `next/navigation` Hooks

# next/navigationのuseRouter, useSearchParams, usePathnameを使ったコンポーネントをテストしたい

`next: 14.0`、`jest: 29.7`の話

次の`<Search>`コンポーネントをテストしようと思ったが、`next/navigation`の`useRouter`, `useSearchParams`, `usePathname`を使っているためどうやってテストしたものかと悩んだ。
結局「モック化しよう！」と考えたが、そのモックにするのでつまづいた。

しかしなんとかモック化したので、モック化の方法を忘れないように書いておく。

ちなみにテストコードは非常に見辛いものになった

* `<Search>`コンポーネント
	* Inputフィールドに文字を入力すると500ミリ秒後にクエリパラメータに反映させる。（例：testと入力すると `/pathname?query=test`が `replace`関数に渡される

```tsx
"use client";

import React from "react";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useDebouncedCallback } from "use-debounce";

export const Search = ({ placeholder }: { placeholder: string }) => {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();

  const handleSearch = useDebouncedCallback((term) => {
    const params = new URLSearchParams(searchParams);
    params.set("page", "1");
    if (term) {
      params.set("query", term);
    } else {
      params.delete("query");
    }
    replace(`${pathname}?${params.toString()}`);
  }, 500);

  return (
    <div className="relative flex flex-1 flex-shrink-0">
      <label htmlFor="search" className="sr-only">
        Search
      </label>
      <input
        className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
        placeholder={placeholder}
        onChange={(e) => {
          handleSearch(e.target.value);
        }}
        defaultValue={searchParams.get("query")?.toString()}
      />
    </div>
  );
};
```
## テストを書く
ちなみに考え方だが
* useRouter => 必要なものをモック化
* usePathname => string返すから適当なパスの文字列を返すようにモック化
* useSearchParams => どうせ`URLSearchParams`とセットで使うんだから、なら`new URLSearchParams({})`を返すようにすればいいじゃん！という考え

```tsx
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as navigation from "next/navigation";
import { Search } from "./Search";

// next/navigation をモック化
jest.mock("next/navigation");

beforeEach(() => {
  // useRouter, useSearchParams, usePathname をモック化
  (navigation.useRouter as jest.Mock).mockImplementation(() => ({
    replace: jest.fn(),
  }));
  (navigation.useSearchParams as jest.Mock).mockImplementation(
    () => new URLSearchParams({})
  );
  (navigation.usePathname as jest.Mock).mockImplementation(() => "/test");
});

afterEach(() => {
  // 各テスト後にモックをリセット
  jest.resetAllMocks();
});

describe("Search Component", () => {
  test("正しく表示されていることをテスト", async () => {
    render(<Search placeholder="test placeholder" />);

    expect(screen.getByPlaceholderText("test placeholder")).toBeInTheDocument();
  });

  test("テキスト入力後、適切なデバウンス時間が経過した後にURLが更新されることをテスト", async () => {
    const replaceMockFn = jest.fn(); // replace のモック
    (navigation.useRouter as jest.Mock).mockImplementation(() => ({
      replace: replaceMockFn,
    }));

    jest.useFakeTimers(); // タイマーをモック化
    const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });

    render(<Search placeholder="test placeholder" />);

    const inputField = screen.getByPlaceholderText("test placeholder");
    await user.type(inputField, "testinput"); // テキストを入力
    act(() => {
      jest.advanceTimersByTime(500); // 500ミリ秒進める
    });
    // replace 関数が正しく呼ばれたか検証
    expect(replaceMockFn).toHaveBeenCalledWith("/test?page=1&query=testinput");
  });

  test("入力をクリアし、適切なデバウンス時間が経過した後にURLのクエリが削除されることをテスト", async () => {
    const replaceMockFn = jest.fn();
    (navigation.useRouter as jest.Mock).mockImplementation(() => ({
      replace: replaceMockFn,
    }));

    // 初期クエリを設定した URLSearchParams オブジェクト
    (navigation.useSearchParams as jest.Mock).mockImplementation(
      () => new URLSearchParams({ query: "john", page: "3" })
    );

    jest.useFakeTimers();
    const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });

    render(<Search placeholder="test placeholder" />);

    const inputField = screen.getByPlaceholderText("test placeholder");
    // 初期値が入力されていることを確認
    expect(inputField).toHaveValue("john");

    await user.clear(inputField);

    act(() => {
      jest.advanceTimersByTime(500);
    });

    // replace 関数が正しく呼ばれたか検証
    expect(replaceMockFn).toHaveBeenCalledWith("/test?page=1");
  });
});
```

## まとめ
「それでいいのか？」と言われたら、もはや「ちゃんとモックを設定できてるか」をテストしているような感じなので良くはないと思う。

## 参考
https://github.com/vercel/next.js/discussions/48937

https://github.com/vercel/next.js/discussions/42527