This guide covers setting up comprehensive testing for TanStack Router applications that use code-based routing, including unit tests, integration tests, and end-to-end testing strategies.
Set up testing by configuring your test framework (Vitest/Jest), creating router test utilities, and implementing patterns for testing navigation, route components, and data loading with manually defined routes.
Using File-Based Routing? See How to Test File-Based Routing for patterns specific to file-based routing applications.
For Vitest (recommended):
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
For Jest:
npm install -D jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom
Create vitest.config.ts:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
typecheck: { enabled: true },
watch: false,
},
})
Create src/test/setup.ts:
import '@testing-library/jest-dom/vitest'
// @ts-expect-error
global.IS_REACT_ACT_ENVIRONMENT = true
The following patterns are specifically designed for applications using code-based routing where you manually create routes with createRoute() and build route trees programmatically.
The TanStack Router team uses this pattern internally for testing router components:
import { beforeEach, afterEach, describe, expect, test, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import {
RouterProvider,
createBrowserHistory,
createRootRoute,
createRoute,
createRouter,
} from '@tanstack/react-router'
import type { RouterHistory } from '@tanstack/react-router'
let history: RouterHistory
beforeEach(() => {
history = createBrowserHistory()
expect(window.location.pathname).toBe('/')
})
afterEach(() => {
history.destroy()
window.history.replaceState(null, 'root', '/')
vi.clearAllMocks()
vi.resetAllMocks()
cleanup()
})
describe('Router Component Testing', () => {
test('should render route component', async () => {
const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <h1>IndexTitle</h1>,
})
const routeTree = rootRoute.addChildren([indexRoute])
const router = createRouter({ routeTree, history })
render(<RouterProvider router={router} />)
expect(await screen.findByText('IndexTitle')).toBeInTheDocument()
})
})
Create src/test/router-utils.tsx:
import React from 'react'
import { render, RenderOptions } from '@testing-library/react'
import {
createRouter,
createRootRoute,
createRoute,
RouterProvider,
Outlet,
} from '@tanstack/react-router'
import { createMemoryHistory } from '@tanstack/react-router'
// Create a root route for testing
const rootRoute = createRootRoute({
component: () => <Outlet />,
})
// Test router factory
export function createTestRouter(routes: any[], initialLocation = '/') {
const routeTree = rootRoute.addChildren(routes)
const router = createRouter({
routeTree,
history: createMemoryHistory({
initialEntries: [initialLocation],
}),
})
return router
}
// Wrapper component for testing
interface RouterWrapperProps {
children: React.ReactNode
router: any
}
function RouterWrapper({ children, router }: RouterWrapperProps) {
return <RouterProvider router={router}>{children}</RouterProvider>
}
// Custom render function with router
interface RenderWithRouterOptions extends Omit<RenderOptions, 'wrapper'> {
router?: any
initialLocation?: string
routes?: any[]
}
export function renderWithRouter(
ui: React.ReactElement,
{
router,
initialLocation = '/',
routes = [],
...renderOptions
}: RenderWithRouterOptions = {},
) {
if (!router && routes.length > 0) {
router = createTestRouter(routes, initialLocation)
}
if (!router) {
throw new Error(
'Router is required. Provide either a router or routes array.',
)
}
function Wrapper({ children }: { children: React.ReactNode }) {
return <RouterWrapper router={router}>{children}</RouterWrapper>
}
return {
...render(ui, { wrapper: Wrapper, ...renderOptions }),
router,
}
}
Create src/test/mock-routes.tsx:
import { createRoute } from '@tanstack/react-router'
import { rootRoute } from './router-utils'
export const createMockRoute = (
path: string,
component: React.ComponentType,
options: any = {},
) => {
return createRoute({
getParentRoute: () => rootRoute,
path,
component,
...options,
})
}
// Common test components
export function TestComponent({ title = 'Test' }: { title?: string }) {
return <div data-testid="test-component">{title}</div>
}
export function LoadingComponent() {
return <div data-testid="loading">Loading...</div>
}
export function ErrorComponent({ error }: { error: Error }) {
return <div data-testid="error">Error: {error.message}</div>
}
import { describe, it, expect } from 'vitest'
import { screen } from '@testing-library/react'
import { createRoute } from '@tanstack/react-router'
import {
renderWithRouter,
rootRoute,
TestComponent,
} from '../test/router-utils'
describe('Code-Based Route Component Testing', () => {
it('should render route component', () => {
const testRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: TestComponent,
})
renderWithRouter(<div />, {
routes: [testRoute],
initialLocation: '/',
})
expect(screen.getByTestId('test-component')).toBeInTheDocument()
})
it('should render component with props from route context', () => {
function ComponentWithContext() {
const { title } = Route.useLoaderData()
return <div data-testid="context-component">{title}</div>
}
const contextRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/context',
component: ComponentWithContext,
loader: () => ({ title: 'From Context' }),
})
renderWithRouter(<div />, {
routes: [contextRoute],
initialLocation: '/context',
})
expect(screen.getByText('From Context')).toBeInTheDocument()
})
})
import { describe, it, expect } from 'vitest'
import { screen } from '@testing-library/react'
import { createRoute } from '@tanstack/react-router'
import { renderWithRouter, rootRoute } from '../test/router-utils'
describe('Route Parameters', () => {
it('should handle route params correctly', () => {
function UserProfile() {
const { userId } = Route.useParams()
return <div data-testid="user-profile">User: {userId}</div>
}
const userRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/users/$userId',
component: UserProfile,
})
renderWithRouter(<div />, {
routes: [userRoute],
initialLocation: '/users/123',
})
expect(screen.getByText('User: 123')).toBeInTheDocument()
})
it('should handle search params correctly', () => {
function SearchPage() {
const { q, page } = Route.useSearch()
return (
<div data-testid="search-results">
Query: {q}, Page: {page}
</div>
)
}
const searchRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/search',
component: SearchPage,
validateSearch: (search) => ({
q: (search.q as string) || '',
page: Number(search.page) || 1,
}),
})
renderWithRouter(<div />, {
routes: [searchRoute],
initialLocation: '/search?q=react&page=2',
})
expect(screen.getByText('Query: react, Page: 2')).toBeInTheDocument()
})
})
import { describe, it, expect } from 'vitest'
import { screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Link, createRoute } from '@tanstack/react-router'
import {
renderWithRouter,
rootRoute,
TestComponent,
} from '../test/router-utils'
describe('Code-Based Route Navigation', () => {
it('should navigate when link is clicked', async () => {
const user = userEvent.setup()
function HomePage() {
return (
<div>
<h1>Home</h1>
<Link to="/about" data-testid="about-link">
About
</Link>
</div>
)
}
function AboutPage() {
return <h1>About Page</h1>
}
const homeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: HomePage,
})
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/about',
component: AboutPage,
})
const { router } = renderWithRouter(<div />, {
routes: [homeRoute, aboutRoute],
initialLocation: '/',
})
// Initial state
expect(screen.getByText('Home')).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/')
// Click link
await user.click(screen.getByTestId('about-link'))
// Check navigation
expect(screen.getByText('About Page')).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/about')
})
it('should navigate programmatically', async () => {
function NavigationTest() {
const navigate = Route.useNavigate()
const handleNavigate = () => {
navigate({ to: '/dashboard', search: { tab: 'settings' } })
}
return (
<div>
<h1>Navigation Test</h1>
<button onClick={handleNavigate} data-testid="navigate-btn">
Go to Dashboard
</button>
</div>
)
}
const testRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: NavigationTest,
})
const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/dashboard',
component: () => <h1>Dashboard</h1>,
validateSearch: (search) => ({
tab: (search.tab as string) || 'general',
}),
})
const { router } = renderWithRouter(<div />, {
routes: [testRoute, dashboardRoute],
initialLocation: '/',
})
await userEvent.click(screen.getByTestId('navigate-btn'))
expect(router.state.location.pathname).toBe('/dashboard')
expect(router.state.location.search).toEqual({ tab: 'settings' })
})
})
import { describe, it, expect } from 'vitest'
import { screen } from '@testing-library/react'
import { createRoute, redirect } from '@tanstack/react-router'
import { renderWithRouter, rootRoute } from '../test/router-utils'
describe('Code-Based Route Guards', () => {
it('should redirect unauthenticated users', () => {
const mockAuth = { isAuthenticated: false }
function ProtectedPage() {
return <h1>Protected Content</h1>
}
function LoginPage() {
return <h1>Login Required</h1>
}
const protectedRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/protected',
component: ProtectedPage,
beforeLoad: ({ context }) => {
if (!mockAuth.isAuthenticated) {
throw redirect({ to: '/login' })
}
},
})
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
component: LoginPage,
})
renderWithRouter(<div />, {
routes: [protectedRoute, loginRoute],
initialLocation: '/protected',
})
// Should redirect to login
expect(screen.getByText('Login Required')).toBeInTheDocument()
})
it('should allow authenticated users', () => {
const mockAuth = { isAuthenticated: true }
function ProtectedPage() {
return <h1>Protected Content</h1>
}
const protectedRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/protected',
component: ProtectedPage,
beforeLoad: ({ context }) => {
if (!mockAuth.isAuthenticated) {
throw redirect({ to: '/login' })
}
},
})
renderWithRouter(<div />, {
routes: [protectedRoute],
initialLocation: '/protected',
})
expect(screen.getByText('Protected Content')).toBeInTheDocument()
})
})
import { describe, it, expect, vi } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { createRoute } from '@tanstack/react-router'
import { renderWithRouter, rootRoute } from '../test/router-utils'
describe('Code-Based Route Data Loading', () => {
it('should load and display data from loader', async () => {
const mockFetchUser = vi.fn().mockResolvedValue({
id: 1,
name: 'John Doe',
email: 'john@example.com',
})
function UserProfile() {
const user = Route.useLoaderData()
return (
<div data-testid="user-profile">
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}
const userRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/users/$userId',
component: UserProfile,
loader: ({ params }) => mockFetchUser(params.userId),
})
renderWithRouter(<div />, {
routes: [userRoute],
initialLocation: '/users/1',
})
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
expect(mockFetchUser).toHaveBeenCalledWith('1')
})
it('should handle loader errors', async () => {
const mockFetchUser = vi.fn().mockRejectedValue(new Error('User not found'))
function UserProfile() {
const user = Route.useLoaderData()
return <div>{user.name}</div>
}
function ErrorComponent({ error }: { error: Error }) {
return <div data-testid="error">Error: {error.message}</div>
}
const userRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/users/$userId',
component: UserProfile,
loader: ({ params }) => mockFetchUser(params.userId),
errorComponent: ErrorComponent,
})
renderWithRouter(<div />, {
routes: [userRoute],
initialLocation: '/users/1',
})
await waitFor(() => {
expect(screen.getByTestId('error')).toBeInTheDocument()
expect(screen.getByText('Error: User not found')).toBeInTheDocument()
})
})
})
import { describe, it, expect, vi } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoute } from '@tanstack/react-router'
import { renderWithRouter, rootRoute } from '../test/router-utils'
describe('React Query Integration', () => {
it('should work with React Query', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const mockFetchPosts = vi.fn().mockResolvedValue([
{ id: 1, title: 'Post 1' },
{ id: 2, title: 'Post 2' },
])
function PostsList() {
const posts = Route.useLoaderData()
return (
<div data-testid="posts-list">
{posts.map((post: any) => (
<div key={post.id}>{post.title}</div>
))}
</div>
)
}
const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/posts',
component: PostsList,
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData({
queryKey: ['posts'],
queryFn: mockFetchPosts,
}),
})
function TestWrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
renderWithRouter(<div />, {
routes: [postsRoute],
initialLocation: '/posts',
wrapper: TestWrapper,
})
await waitFor(() => {
expect(screen.getByText('Post 1')).toBeInTheDocument()
expect(screen.getByText('Post 2')).toBeInTheDocument()
})
})
})
import { describe, it, expect } from 'vitest'
import { screen } from '@testing-library/react'
import {
createRootRouteWithContext,
createRoute,
Outlet,
} from '@tanstack/react-router'
interface RouterContext {
auth: {
user: { id: string; name: string } | null
isAuthenticated: boolean
}
}
describe('Code-Based Router Context', () => {
it('should provide context to routes', () => {
const rootRouteWithContext = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
})
function UserDashboard() {
const { auth } = Route.useRouteContext()
return (
<div data-testid="dashboard">
Welcome, {auth.user?.name || 'Guest'}!
</div>
)
}
const dashboardRoute = createRoute({
getParentRoute: () => rootRouteWithContext,
path: '/dashboard',
component: UserDashboard,
})
const mockContext = {
auth: {
user: { id: '1', name: 'John Doe' },
isAuthenticated: true,
},
}
const router = createRouter({
routeTree: rootRouteWithContext.addChildren([dashboardRoute]),
context: mockContext,
history: createMemoryHistory({
initialEntries: ['/dashboard'],
}),
})
render(<RouterProvider router={router} />)
expect(screen.getByText('Welcome, John Doe!')).toBeInTheDocument()
})
})
Create playwright.config.ts:
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
Create e2e/navigation.spec.ts:
import { test, expect } from '@playwright/test'
test.describe('Code-Based Router Navigation', () => {
test('should navigate between pages', async ({ page }) => {
await page.goto('/')
// Check home page
await expect(page.locator('h1')).toContainText('Home')
// Navigate to about page
await page.click('text=About')
await expect(page).toHaveURL('/about')
await expect(page.locator('h1')).toContainText('About')
// Use browser back button
await page.goBack()
await expect(page).toHaveURL('/')
await expect(page.locator('h1')).toContainText('Home')
})
test('should handle search parameters', async ({ page }) => {
await page.goto('/search?q=react')
await expect(page.locator('[data-testid="search-input"]')).toHaveValue(
'react',
)
await expect(page).toHaveURL('/search?q=react')
// Update search
await page.fill('[data-testid="search-input"]', 'vue')
await page.press('[data-testid="search-input"]', 'Enter')
await expect(page).toHaveURL('/search?q=vue')
})
test('should handle authentication flow', async ({ page }) => {
// Try to access protected route
await page.goto('/dashboard')
// Should redirect to login
await expect(page).toHaveURL('/login')
// Login
await page.fill('[data-testid="username"]', 'testuser')
await page.fill('[data-testid="password"]', 'password')
await page.click('[data-testid="login-btn"]')
// Should redirect back to dashboard
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('h1')).toContainText('Dashboard')
})
})
src/
├── components/
│ ├── Header.tsx
│ └── Header.test.tsx
├── routes/
│ ├── posts.tsx # Code-based route definitions
│ ├── posts.test.tsx
│ └── index.tsx
├── test/
│ ├── setup.ts
│ ├── router-utils.tsx # Code-based router utilities
│ └── mock-routes.tsx # Manual route factories
└── __tests__/
├── integration/
└── e2e/
// Mock external dependencies for code-based routes
vi.mock('../api/users', () => ({
fetchUser: vi.fn(),
updateUser: vi.fn(),
}))
// Test utility for common code-based route setups
export function createAuthenticatedRouter(user = mockUser) {
// Manually create routes for testing
const protectedRoutes = [
createRoute({
getParentRoute: () => rootRoute,
path: '/dashboard',
component: DashboardComponent,
}),
]
return createTestRouter(protectedRoutes, {
context: {
auth: { user, isAuthenticated: true },
},
})
}
// Group related tests
describe('User Management', () => {
describe('when authenticated', () => {
it('should show user dashboard', () => {
// Test implementation
})
})
describe('when not authenticated', () => {
it('should redirect to login', () => {
// Test implementation
})
})
})
Problem: Tests fail with "window is not defined" errors.
Solution: Ensure jsdom environment is configured:
// vitest.config.ts
export default defineConfig({
test: {
environment: 'jsdom',
},
})
Problem: Components can't access router context in tests.
Solution: Use the custom render function with router:
// ✅ Correct
renderWithRouter(<Component />, { routes, initialLocation })
// ❌ Wrong
render(<Component />)
Problem: Tests fail because they don't wait for data loading.
Solution: Use proper async testing patterns:
await waitFor(() => {
expect(screen.getByText('Loaded Data')).toBeInTheDocument()
})
After setting up code-based routing testing, you might want to: