코드 기반 라우팅으로 테스트 설정 방법
이 가이드에서는 코드 기반 라우팅을 사용하는 TanStack Router 애플리케이션에서 단위 테스트, 통합 테스트, 엔드 투 엔드 테스트 전략을 포함한 종합적인 테스트를 설정하는 방법을 다룹니다.
빠른 시작
테스트 프레임워크(Vitest/Jest)를 구성하고, 라우터 테스트 유틸리티를 만들며, 수동으로 정의한 라우트의 탐색, 라우트 컴포넌트 및 데이터 로딩을 테스트하는 패턴을 구현해 테스트를 설정합니다.
파일 기반 라우팅을 사용하나요? 파일 기반 라우팅 애플리케이션에 특화된 패턴은 파일 기반 라우팅 테스트 방법을 참고합니다.
테스트 프레임워크 구성
1. 의존성 설치
Vitest(권장)의 경우:
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
Jest의 경우:
npm install -D jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom
2. Vitest 구성
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,
},
})
3. 테스트 설정 만들기
src/test/setup.ts를 만듭니다.
import '@testing-library/jest-dom/vitest'
// @ts-expect-error
global.IS_REACT_ACT_ENVIRONMENT = true
코드 기반 라우터 테스트 패턴
다음 패턴은 createRoute()로 라우트를 수동으로 만들고 프로그래밍 방식으로 라우트 트리를 구성하는 코드 기반 라우팅 애플리케이션을 위해 설계되었습니다.
1. TanStack Router 내부 패턴(권장)
TanStack Router 팀은 라우터 컴포넌트를 테스트할 때 내부적으로 이 패턴을 사용합니다.
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()
})
})
2. 대안: 라우터 테스트 유틸리티(간단한 경우)
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,
}
}
2. 모의 라우트 팩토리
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>
}
코드 기반 라우트 컴포넌트 테스트
1. 기본 컴포넌트 테스트
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()
})
})
2. 라우트 매개변수 테스트
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()
})
})
탐색 테스트
1. Link 컴포넌트 테스트
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' })
})
})
2. 라우트 가드 테스트
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()
})
})
데이터 로딩 테스트
1. 로더 테스트
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()
})
})
})
2. React Query와 함께 테스트
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()
})
})
})
컨텍스트와 함께 테스트
1. 라우터 컨텍스트 테스트
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()
})
})
Playwright로 E2E 테스트
1. Playwright 구성
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,
},
})
2. E2E 테스트 예시
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')
})
})
코드 기반 라우팅 테스트 모범 사례
1. 테스트 구성
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/
2. 일반적인 패턴
// 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
})
})
})
일반적인 문제
테스트 환경 문제
문제: "window is not defined" 오류로 테스트가 실패합니다.
해결 방법: jsdom 환경이 구성되어 있는지 확인합니다.
// vitest.config.ts
export default defineConfig({
test: {
environment: 'jsdom',
},
})
라우터 컨텍스트 누락
문제: 테스트에서 컴포넌트가 라우터 컨텍스트에 액세스할 수 없습니다.
해결 방법: 라우터와 함께 사용자 지정 렌더링 함수를 사용합니다.
// ✅ Correct
renderWithRouter(<Component />, { routes, initialLocation })
// ❌ Wrong
render(<Component />)
비동기 데이터 로딩
문제: 데이터 로딩을 기다리지 않아 테스트가 실패합니다.
해결 방법: 적절한 비동기 테스트 패턴을 사용합니다.
await waitFor(() => {
expect(screen.getByText('Loaded Data')).toBeInTheDocument()
})
일반적인 다음 단계
코드 기반 라우팅 테스트를 설정한 후 다음 작업을 수행할 수 있습니다.
- 파일 기반 라우팅 테스트 방법 - 파일 기반 라우팅 앱에 특화된 패턴
- 기본 인증 설정 방법 - 인증 흐름 테스트
- 일반적인 라우터 문제 디버깅 방법 - 테스트 실패 디버깅
관련 리소스
- 코드 기반 라우팅 가이드 - 코드 기반 라우팅 이해
- Vitest 문서 - 테스트 프레임워크
- Testing Library React - 컴포넌트 테스트 유틸리티
- Playwright 문서 - E2E 테스트 프레임워크
- TanStack Router 예시 - 테스트 설정 예시