0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Mocking `next/navigation` Hooks

Posted at

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

next: 14.0jest: 29.7の話

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

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

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

  • <Search>コンポーネント
    • Inputフィールドに文字を入力すると500ミリ秒後にクエリパラメータに反映させる。(例:testと入力すると /pathname?query=testreplace関数に渡される
"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");
  });
});

まとめ

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

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?