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
関数に渡される
- Inputフィールドに文字を入力すると500ミリ秒後にクエリパラメータに反映させる。(例:testと入力すると
"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({})
を返すようにすればいいじゃん!という考え
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");
});
});
まとめ
「それでいいのか?」と言われたら、もはや「ちゃんとモックを設定できてるか」をテストしているような感じなので良くはないと思う。
参考