Open3

TanStack Routerにおけるコンポーネントテスト

じょうげんじょうげん

useNavigate()Route.useParams()など、TanStack Router由来のカスタムフックを1つでも使用しているコンポーネントは、RouterProviderでラップしておかないとモック化すらすることができず、エラーが出てしまう。

テスト用のRouterを作成し、render関数内でそれを利用することで正常に動作させることができる。

import { Providers } from "@/routes/-root";
import "@testing-library/jest-dom/vitest";
import { render as rtlRender, type RenderOptions } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type ReactElement } from "react";
import { TestRouter } from "./router";

type RenderReturn = ReturnType<typeof rtlRender> & {
  user: ReturnType<typeof userEvent.setup>;
};

export const render = (
  ui: ReactElement,
  options?: Omit<RenderOptions, "wrapper">,
): RenderReturn => {
  const user = userEvent.setup();
  const result = rtlRender(<TestRouter component={() => <Providers>{ui}</Providers>} />, {
    ...options,
  });
  return { user, ...result };
};

export { within } from "@testing-library/dom";
export { screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react";
router.tsx
import {
  Outlet,
  RouterProvider,
  createMemoryHistory,
  createRootRoute,
  createRoute,
  createRouter,
} from "@tanstack/react-router";
import { type FC, type ReactNode } from "react";

export const TestRouter: FC<{ component: () => ReactNode }> = ({ component }) => {
  const rootRoute = createRootRoute({
    component: Outlet,
  });

  const indexRoute = createRoute({
    getParentRoute: () => rootRoute,
    path: "/",
    component,
  });

  const routeTree = rootRoute.addChildren([indexRoute]);
  const history = createMemoryHistory({ initialEntries: ["/"] });
  const router = createRouter({ routeTree, history });
  return (
      {/* @ts-expect-error router */}
      <RouterProvider router={router} />
  );
};
じょうげんじょうげん

StoryBook用

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
  type NotFoundRouteProps,
  RouterProvider,
  createMemoryHistory,
  createRootRoute,
  createRoute,
  createRouter,
  useRouterState,
} from "@tanstack/react-router";
import React, { type ReactNode, createContext, useContext } from "react";

function RenderStory() {
  const storyFn = useContext(CurrentStoryContext);
  if (!storyFn) {
    throw new Error("Storybook root not found");
  }
  return storyFn();
}

export const CurrentStoryContext = createContext<(() => ReactNode) | undefined>(undefined);

function NotFoundComponent(_props: NotFoundRouteProps) {
  const state = useRouterState();
  return (
    <div>
      <i>Warning:</i> Simulated route not found for path <code>{state.location.href}</code>
    </div>
  );
}

const storyPath = "/__story__";
const storyRoute = createRoute({
  path: storyPath,
  getParentRoute: () => rootRoute,
  component: RenderStory,
});

const rootRoute = createRootRoute({
  notFoundComponent: NotFoundComponent,
});
rootRoute.addChildren([storyRoute]);

export const storyRouter = createRouter({
  history: createMemoryHistory({ initialEntries: [storyPath] }),
  routeTree: rootRoute,
});

/** StoryBook用ダミーRouterDecorator */
export function storyRouterDecorator(storyFn: () => ReactNode) {
  return (
    <CurrentStoryContext.Provider value={storyFn}>
        <RouterProvider router={storyRouter} />
    </CurrentStoryContext.Provider>
  );
}
じょうげんじょうげん

Route hookをモック化する方法がずっとわからずに、外部から注入するようにしていた。
getRouteApi()を使えばモック化できることがわかった。

import { Route } = "@/routes/_authenticated/user/$userId"
const { useRouteContext, useParams } = Route

export const UserDetailPage = () => {
  const { title } = useRouteContext();
  const params = useParams();
  return (
    ...
  )
}

これだとRouteをモック化できない

const { useRouteContext, useParams } = getRouteApi("/_authenticated/user/$userId/");

export const UserDetailPage = () => {
  const { title } = useRouteContext();
  const params = useParams();
  return (
    ...
  )
}

これなら正しく動作する

vi.mock(import("@tanstack/react-router"), async (importActual) => {
  const actual = await importActual();
  return {
    ...actual,
    getRouteApi: (): any => ({
      useRouteContext: () => ({ title: "ユーザ詳細" }),
      useParams: () => ({userId: "mock-id"}),
    }),
  };
});