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"}),
}),
};
});