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()
If you use these hooks, you will need the router within context

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} />
}
A simple router provider

Then in a test just wrap it in an act

const view = await act(() =>
      render(
        <div>
          <EmptyRouterProvider>
            <MyComponentWithLocation />
          </EmptyRouterProvider>
        </div>
      )
    )
I can't remember why act is needed :(

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,
  }
}
Yeah there's a lot going on there

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")
That nasty helper makes life easy

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.