After using TanStack Router alongside TanStack Start recently, a common complaint I've seen has been: "it's good but I can't test it" and other complaints about a lack of testing examples.
So, I thought it would a good idea to put down some thoughts and examples of testing pages when using TanStack Router.
Simple cases
Sometimes you are testing a component that requries the Router to be available in context. The component may not do anything particularly interesting with the router, maybe it just needs the location current location for something:
import { useLocation, useRouter, type LinkProps } from "@tanstack/react-router"
// Within a component
const router = useRouter()
const { pathname } = useLocation()
In this case, you may just get away with a blank router provider, which is pretty simple to create:
import type { PropsWithChildren } from "react"
import {
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from "@tanstack/react-router"
export const EmptyRouterProvider = (props: PropsWithChildren) => {
const rootRoute = createRootRoute({
component: () => props.children,
})
const router = createRouter({
routeTree: rootRoute.addChildren([
createRoute({
path: "*",
component: () => props.children,
getParentRoute: () => rootRoute,
}),
]),
})
return <RouterProvider router={router} />
}
Then in a test just wrap it in an act
const view = await act(() =>
render(
<div>
<EmptyRouterProvider>
<MyComponentWithLocation />
</EmptyRouterProvider>
</div>
)
)
Alternatively avoid using router hooks in Components
Typically, I would try to avoid the above in tests. It's not that it's necessarily wrong, but I prefer components to have the data required passed down to them and leave any router or loading logic up to the router. This leds to having a src/routes
folder with all the routes and /src/pages
for the pages rendered. The RouteComponent
within a route would be used for calling all the router or TanStack Query hooks.
For example, the simple test above could've been simplified if the component took a location
or path
prop. The route can make the useLocation
call and pass the required data to a Page
This style results in simple components that just take data (or if needs be, QueryResult<MyData>
) or generic functions; which are very easy to test. This means no wrapping in QueryProviders, mocking fetch, etc. when testing the page.
This leads to all the loading and routing taking place in the route files, so we need to test the route code thoroughly. Treating those tests more like integration tests, then supplementing those tests with Playwright, Cypress, etc. So how do we write such a test?
Testing Routes
This requires a little more setup; thankfully, you only need to do it once. If you don't use TanStack Query then you could simplify all this.
The following snippet may be a little complex, but it sets up a router using your projects generated routes file with a QueryClient:
import { QueryClient } from "@tanstack/react-query"
import {
createMemoryHistory,
createRouter,
RouterProvider,
} from "@tanstack/react-router"
import { render } from "@testing-library/react"
import { QueryProvider } from "~/integrations/tanstack/root-provider"
import { routeTree } from "~/route-tree.gen"
export function makeRouter({
initialRoute,
contextData = defaultContext, // Your routers context, this could be session info for example
queryClient = makeTestQueryClient(),
}: {
initialRoute: string
contextData?: YourRouterContext
queryClient?: QueryClient
}) {
const memoryHistory = createMemoryHistory({
initialEntries: [initialRoute],
initialIndex: 0,
})
const router = createRouter({
defaultPendingMinMs: 0,
history: memoryHistory,
routeTree: routeTree,
context: {
queryClient: queryClient,
contextData: contextData,
},
})
return router
}
export type TestRouter = ReturnType<typeof makeRouter>
export async function navigateToRoute(
router: TestRouter,
navigateProps: Parameters<TestRouter["navigate"]>[0]
) {
await act(() => router.navigate(navigateProps))
}
export const makeTestQueryClient = () =>
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
export async function renderWithRouter({
initialRoute,
session = emptySession,
queryClient = makeTestQueryClient(),
}: {
initialRoute: string
context?: RouterContext // Your router's context!
queryClient?: QueryClient
}) {
const router = makeRouter({
initialRoute,
session,
queryClient,
})
const app = render(
<QueryProvider client={queryClient}>
<RouterProvider<typeof router> router={router} />
</QueryProvider>
)
await navigateToRoute(router, {
to: initialRoute,
})
return {
router,
app,
}
}
What you really need to care about in that is renderWithRouter
which allows you to test your routes easily.
A quick example
Lets imagine you have a route that prefetches data as part of it's preload
then uses that data later. How do we test this given the above?
// This is just a helper for mocking fetch using vitest
const fetchMock = setupFetchMock()
// Lets just
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => await {the_data: "is good"},
} as Response )
// Lets create our component
// We can use a real path to test search validation/etc
const { app, router } = await renderWithRouter({
initialRoute: "/my-page?query=something",
})
// You can test the fetch call was made
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining(
"/my-api/some-network-call?user-query=something"
),
expect.objectContaining({
method: "GET",
headers: { Accept: "application/json" },
})
)
// You test using `app` like you would in RTL. I'd only do
// minimal checks here and test the page individually:
const heading = await waitFor(() =>
app.getByRole("heading", {
name: "The Page",
})
)
expect(heading).toBeInTheDocument()
// Or test the router
expect(router.state.location.pathname).toBe("/my-page")
So there you go. You are directly testing the route component here using the actual router. From here you test the page if you want via app
, the router by accessing it, and the fetch calls (or use MSW if that's your thing)
Likewise, you get to provide the initial context. So if you use the router context for holding an auth session you can provide a fake one when testing authenticated routes, and test your navigation when no session is provided.
As mentioned prior, I prefer to split data/routing from the page itself. Stick to testing routes, context related functions, and data loading in these tests. Then use that to create a page where you can test interactivity.