Route loaders run inside TanStack Router's lifecycle. You need to verify that loaders return the right data for given params, search params, and async conditions -- without spinning up a full app.
import { createRouterHarness } from '@tanstack-router-testing/react-router-testing';
import { createRootRoute, createRoute, createRouteMask } from '@tanstack/react-router';
import { describe, it, expect, afterEach } from 'vitest';Define a route with a synchronous loader, create a harness, load, and assert.
const rootRoute = createRootRoute();
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
loader: () => ({ message: 'hello' }),
});
const routeTree = rootRoute.addChildren([indexRoute]);
describe('index loader', () => {
let harness: ReturnType<typeof createRouterHarness>;
afterEach(() => harness.cleanup());
it('returns the expected data', async () => {
harness = createRouterHarness({ routeTree, initialEntries: ['/'] });
await harness.load();
expect(harness.getLoaderData('/')).toEqual({ message: 'hello' });
});
});Loaders that return promises are resolved before load() settles.
const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/posts',
loader: async () => {
// Simulates an API call
return { posts: [{ id: 1, title: 'First' }] };
},
});
const routeTree = rootRoute.addChildren([postsRoute]);
it('resolves async loader data', async () => {
harness = createRouterHarness({ routeTree, initialEntries: ['/posts'] });
await harness.load();
expect(harness.getLoaderData('/posts')).toEqual({
posts: [{ id: 1, title: 'First' }],
});
});Access params through the loader's context argument. Use initialEntries to set the URL.
const postRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/posts/$postId',
loader: async ({ params }) => {
return { id: params.postId, title: `Post ${params.postId}` };
},
});
const routeTree = rootRoute.addChildren([postRoute]);
it('receives parsed params', async () => {
harness = createRouterHarness({
routeTree,
initialEntries: ['/posts/42'],
});
await harness.load();
expect(harness.getLoaderData('/posts/$postId')).toEqual({
id: '42',
title: 'Post 42',
});
expect(harness.getParams('/posts/$postId')).toEqual({ postId: '42' });
});Validate search params with validateSearch, then use them in the loader.
import { zodValidator } from '@tanstack/zod-adapter';
import { z } from 'zod';
const searchRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/search',
validateSearch: z.object({ page: z.number().default(1) }),
loader: async ({ search }) => {
return { results: [], page: search.page };
},
});
const routeTree = rootRoute.addChildren([searchRoute]);
it('uses validated search params', async () => {
harness = createRouterHarness({
routeTree,
initialEntries: ['/search?page=3'],
});
await harness.load();
expect(harness.getLoaderData('/search')).toEqual({ results: [], page: 3 });
expect(harness.getSearch('/search')).toEqual({ page: 3 });
});When using file-based routing, import the route directly and pass it to the harness. Ancestor loaders are automatically neutered for isolation.
import { createRouterHarness } from '@tanstack-router-testing/react-router-testing';
import { Route } from './routes/posts.$postId';
it('loads a file-based route with typed params', async () => {
const harness = createRouterHarness({
route: Route,
params: { postId: '42' }, // fully typed
});
await harness.load();
expect(harness.getLoaderData(Route)).toBeDefined();
harness.cleanup();
});Override loader data to skip the real loader (useful when loaders make HTTP calls):
it('uses stubbed loader data', async () => {
const harness = createRouterHarness({
route: Route,
params: { postId: '42' },
loaderData: { id: '42', title: 'Stubbed' },
});
await harness.load();
expect(harness.getLoaderData(Route)).toEqual({ id: '42', title: 'Stubbed' });
harness.cleanup();
});Search params work the same way:
import { Route as SearchRoute } from './routes/search';
it('passes typed search params', async () => {
const harness = createRouterHarness({
route: SearchRoute,
search: { page: 3 }, // typed from validateSearch
});
await harness.load();
expect(harness.getSearch(SearchRoute)).toEqual({ page: 3 });
harness.cleanup();
});- Forgetting
await harness.load()--getLoaderData()returnsundefineduntil the router finishes loading. Always awaitload()before asserting. - Wrong match target --
getLoaderData()accepts a route id, routeId, or fullPath string. If your route is/posts/$postId, pass that exact string, not/posts/42. - Missing cleanup -- Call
harness.cleanup()inafterEachto destroy the history listener and prevent leaks across tests. - Navigate vs. load --
harness.load()loads the current location. To test a different URL after creation, useharness.navigate({ to: '/other' })which navigates and waits for the transition to settle.