파일 기반 라우팅으로 라우터를 테스트하는 방법
이 가이드에서는 파일 기반 라우팅을 사용하는 TanStack Router 애플리케이션의 테스트 방법을 설명하며, 라우트 생성, 파일 기반 라우트 컴포넌트, 파일 기반 라우팅 패턴 테스트를 포함합니다.
빠른 시작
라우트 모킹 유틸리티를 설정하고, 생성된 라우트 트리를 테스트하고, 파일 기반 라우트 구조와 규칙에 맞는 패턴을 구현하여 파일 기반 라우팅을 테스트합니다.
파일 기반 라우팅 테스트 이해
파일 기반 라우팅 테스트는 다음과 같은 몇 가지 주요 측면에서 코드 기반 라우팅 테스트와 다릅니다.
- 생성된 라우트 트리: 파일 시스템 구조에서 라우트가 자동으로 생성됩니다.
- 파일 규칙: 라우트는 특정 파일 이름 규칙(
index.tsx,route.tsx,$param.tsx)을 따릅니다. - 라우트 검색: 명시적 import가 아니라 파일 시스템 스캔을 통해 라우트를 검색합니다.
- 타입 생성: 라우트 타입이 자동으로 생성되므로 특별한 테스트 고려 사항이 필요합니다.
파일 기반 라우트 테스트 설정
1. 테스트 종속성 설치
파일 기반 라우팅 테스트에는 일반 라우터 테스트와 동일한 기본 종속성이 필요합니다.
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
2. 테스트 환경 구성
파일 기반 라우팅을 지원하도록 vitest.config.ts를 만듭니다.
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
tanstackRouter({
// Configure for test environment
routesDirectory: './src/routes',
generatedRouteTree: './src/routeTree.gen.ts',
disableLogging: true,
}),
react(),
],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
typecheck: { enabled: true },
watch: false,
// Ensure route tree is generated before tests
globals: true,
},
})
3. 라우트 테스트 유틸리티 만들기
src/test/file-route-utils.tsx를 만듭니다.
import React from 'react'
import { render, RenderOptions } from '@testing-library/react'
import {
createRouter,
RouterProvider,
createMemoryHistory,
} from '@tanstack/react-router'
// Import the generated route tree
import { routeTree } from '../routeTree.gen'
// Create test router with generated route tree
export function createTestRouterFromFiles(initialLocation = '/') {
const router = createRouter({
routeTree,
history: createMemoryHistory({
initialEntries: [initialLocation],
}),
context: {
// Add any required context for your routes
},
})
return router
}
// Custom render function for file-based routes
interface RenderWithFileRoutesOptions extends Omit<RenderOptions, 'wrapper'> {
initialLocation?: string
routerContext?: any
}
export function renderWithFileRoutes(
ui: React.ReactElement,
{
initialLocation = '/',
routerContext = {},
...renderOptions
}: RenderWithFileRoutesOptions = {},
) {
const router = createRouter({
routeTree,
history: createMemoryHistory({
initialEntries: [initialLocation],
}),
context: routerContext,
})
function Wrapper({ children }: { children: React.ReactNode }) {
return <RouterProvider router={router}>{children}</RouterProvider>
}
return {
...render(ui, { wrapper: Wrapper, ...renderOptions }),
router,
}
}
// Helper to test specific file routes
export function createMockFileRoute(
path: string,
component: React.ComponentType,
) {
// This is useful for isolated testing when you don't want to use the full route tree
return {
path,
component,
// Add other common route properties as needed
}
}
파일 기반 라우트 구조 테스트
1. 라우트 트리 생성 테스트
import { describe, it, expect } from 'vitest'
import { routeTree } from '../routeTree.gen'
describe('Generated Route Tree', () => {
it('should generate route tree from file structure', () => {
// Test that route tree exists and has expected structure
expect(routeTree).toBeDefined()
expect(routeTree.children).toBeDefined()
})
it('should include all expected routes', () => {
// Get all route paths from the generated tree
const getAllRoutePaths = (tree: any, paths: string[] = []): string[] => {
if (tree.path) {
paths.push(tree.path)
}
if (tree.children) {
tree.children.forEach((child: any) => {
getAllRoutePaths(child, paths)
})
}
return paths
}
const routePaths = getAllRoutePaths(routeTree)
// Test that expected routes are present
expect(routePaths).toContain('/')
expect(routePaths).toContain('/about')
// Add assertions for your specific routes
})
it('should have correct route hierarchy', () => {
// Test parent-child relationships
const homeRoute = routeTree.children?.find(
(child: any) => child.path === '/',
)
expect(homeRoute).toBeDefined()
// Test for specific route structure based on your file organization
// For example, if you have /posts/$postId routes:
// const postsRoute = routeTree.children?.find((child: any) => child.path === '/posts')
// expect(postsRoute?.children).toBeDefined()
})
})
2. 파일 라우트 규칙 테스트
import { describe, it, expect } from 'vitest'
import { screen } from '@testing-library/react'
import { renderWithFileRoutes } from '../test/file-route-utils'
describe('File Route Conventions', () => {
it('should render index route at root path', () => {
renderWithFileRoutes(<div />, {
initialLocation: '/',
})
// Test that the index route component renders
// This depends on what your src/routes/index.tsx exports
expect(screen.getByText('Welcome Home!')).toBeInTheDocument()
})
it('should handle route parameters from filename', () => {
// If you have a route like src/routes/posts/$postId.tsx
renderWithFileRoutes(<div />, {
initialLocation: '/posts/123',
})
// Test that parameter is correctly parsed from file-based route
expect(screen.getByText(/Post.*123/)).toBeInTheDocument()
})
it('should handle nested routes from directory structure', () => {
// If you have src/routes/dashboard/settings.tsx
renderWithFileRoutes(<div />, {
initialLocation: '/dashboard/settings',
})
expect(screen.getByText(/Settings/)).toBeInTheDocument()
})
it('should handle layout routes', () => {
// If you have src/routes/_layout.tsx
renderWithFileRoutes(<div />, {
initialLocation: '/some-nested-route',
})
// Test that layout is rendered for nested routes
expect(screen.getByTestId('layout-header')).toBeInTheDocument()
})
})
파일 기반 라우트 컴포넌트 테스트
1. 개별 라우트 파일 테스트
import { describe, it, expect } from 'vitest'
import { screen } from '@testing-library/react'
import { createFileRoute } from '@tanstack/react-router'
import { renderWithFileRoutes } from '../test/file-route-utils'
describe('Individual Route Components', () => {
it('should test about route component', () => {
renderWithFileRoutes(<div />, {
initialLocation: '/about',
})
expect(screen.getByText('About')).toBeInTheDocument()
})
it('should test route with loader data', () => {
// For a route like src/routes/posts/index.tsx with loader
renderWithFileRoutes(<div />, {
initialLocation: '/posts',
})
// Wait for loader data to load
expect(screen.getByText(/Posts List/)).toBeInTheDocument()
})
it('should test route with search params validation', () => {
// For a route with validateSearch in src/routes/search.tsx
renderWithFileRoutes(<div />, {
initialLocation: '/search?q=react&page=1',
})
expect(screen.getByDisplayValue('react')).toBeInTheDocument()
})
})
2. 라우트별 훅 테스트
import { describe, it, expect } from 'vitest'
import { screen } from '@testing-library/react'
import { renderWithFileRoutes } from '../test/file-route-utils'
describe('Route-Specific Hooks', () => {
it('should test useParams in parameterized route', () => {
// Create a test component that uses Route.useParams()
function TestComponent() {
// This would be available in the actual route component
const params = Route.useParams()
return <div data-testid="param-value">{params.postId}</div>
}
renderWithFileRoutes(<TestComponent />, {
initialLocation: '/posts/abc123',
})
expect(screen.getByTestId('param-value')).toHaveTextContent('abc123')
})
it('should test useLoaderData in route with loader', () => {
renderWithFileRoutes(<div />, {
initialLocation: '/posts/123',
})
// Test that loader data is available in the component
expect(screen.getByText(/Post Title/)).toBeInTheDocument()
})
it('should test useSearch in route with search validation', () => {
renderWithFileRoutes(<div />, {
initialLocation: '/search?q=typescript&sort=date',
})
// Test that search params are correctly parsed
expect(screen.getByDisplayValue('typescript')).toBeInTheDocument()
expect(screen.getByText(/sorted by date/)).toBeInTheDocument()
})
})
파일 기반 라우트로 라우트 탐색 테스트
1. Link 탐색 테스트
import { describe, it, expect } from 'vitest'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithFileRoutes } from '../test/file-route-utils'
describe('File-Based Route Navigation', () => {
it('should navigate between file-based routes', async () => {
const user = userEvent.setup()
const { router } = renderWithFileRoutes(<div />, {
initialLocation: '/',
})
// Initial state - should be on home route
expect(screen.getByText('Welcome Home!')).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/')
// Click navigation link
await user.click(screen.getByRole('link', { name: /about/i }))
// Should navigate to about route
expect(screen.getByText('About')).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/about')
})
it('should handle dynamic route navigation', async () => {
const user = userEvent.setup()
renderWithFileRoutes(<div />, {
initialLocation: '/posts',
})
// Click on a post link (assuming your posts route renders links)
await user.click(screen.getByRole('link', { name: /View Post 1/i }))
// Should navigate to dynamic post route
expect(screen.getByText(/Post 1 Details/)).toBeInTheDocument()
})
it('should handle nested route navigation', async () => {
const user = userEvent.setup()
renderWithFileRoutes(<div />, {
initialLocation: '/dashboard',
})
// Navigate to nested route
await user.click(screen.getByRole('link', { name: /settings/i }))
expect(screen.getByText(/Dashboard Settings/)).toBeInTheDocument()
})
})
2. 프로그래밍 방식 탐색 테스트
import { describe, it, expect } from 'vitest'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithFileRoutes } from '../test/file-route-utils'
describe('Programmatic Navigation', () => {
it('should programmatically navigate between file routes', async () => {
const user = userEvent.setup()
const { router } = renderWithFileRoutes(<div />, {
initialLocation: '/',
})
// Trigger programmatic navigation (button in your component)
await user.click(screen.getByRole('button', { name: /Go to Posts/i }))
expect(router.state.location.pathname).toBe('/posts')
})
it('should navigate with search params', async () => {
const user = userEvent.setup()
const { router } = renderWithFileRoutes(<div />, {
initialLocation: '/search',
})
// Trigger search with params
await user.type(screen.getByRole('textbox'), 'test query')
await user.click(screen.getByRole('button', { name: /search/i }))
expect(router.state.location.search).toMatchObject({
q: 'test query',
})
})
})
파일 기반 라우트 가드 및 로더 테스트
1. 라우트 가드 테스트
import { describe, it, expect, vi } from 'vitest'
import { screen } from '@testing-library/react'
import { renderWithFileRoutes } from '../test/file-route-utils'
describe('File-Based Route Guards', () => {
it('should redirect unauthenticated users from protected routes', () => {
// Mock unauthenticated state
const mockAuth = { isAuthenticated: false, user: null }
renderWithFileRoutes(<div />, {
initialLocation: '/dashboard',
routerContext: { auth: mockAuth },
})
// Should redirect to login (based on your beforeLoad implementation)
expect(screen.getByText(/Please log in/)).toBeInTheDocument()
})
it('should allow authenticated users to access protected routes', () => {
const mockAuth = {
isAuthenticated: true,
user: { id: '1', name: 'John' },
}
renderWithFileRoutes(<div />, {
initialLocation: '/dashboard',
routerContext: { auth: mockAuth },
})
expect(screen.getByText(/Welcome to Dashboard/)).toBeInTheDocument()
})
})
2. 라우트 로더 테스트
import { describe, it, expect, vi } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { renderWithFileRoutes } from '../test/file-route-utils'
describe('File-Based Route Loaders', () => {
it('should load data for route with loader', async () => {
// Mock the API function used in your route loader
const mockFetchPost = vi.fn().mockResolvedValue({
id: '123',
title: 'Test Post',
content: 'Test content',
})
// If your route loader uses a global API function, mock it
vi.mock('../api/posts', () => ({
fetchPost: mockFetchPost,
}))
renderWithFileRoutes(<div />, {
initialLocation: '/posts/123',
})
await waitFor(() => {
expect(screen.getByText('Test Post')).toBeInTheDocument()
})
expect(mockFetchPost).toHaveBeenCalledWith('123')
})
it('should handle loader errors', async () => {
const mockFetchPost = vi.fn().mockRejectedValue(new Error('Post not found'))
vi.mock('../api/posts', () => ({
fetchPost: mockFetchPost,
}))
renderWithFileRoutes(<div />, {
initialLocation: '/posts/invalid',
})
await waitFor(() => {
expect(screen.getByText(/Error.*Post not found/)).toBeInTheDocument()
})
})
})
파일 라우트 검증 테스트
1. 검색 매개변수 검증 테스트
import { describe, it, expect } from 'vitest'
import { screen } from '@testing-library/react'
import { renderWithFileRoutes } from '../test/file-route-utils'
describe('File Route Validation', () => {
it('should validate search parameters', () => {
// Test with valid search params
renderWithFileRoutes(<div />, {
initialLocation: '/search?q=react&page=1&sort=date',
})
expect(screen.getByDisplayValue('react')).toBeInTheDocument()
expect(screen.getByText(/Page 1/)).toBeInTheDocument()
})
it('should handle invalid search parameters', () => {
// Test with invalid search params (e.g., invalid page number)
renderWithFileRoutes(<div />, {
initialLocation: '/search?page=invalid&sort=unknown',
})
// Should fall back to defaults based on your validation schema
expect(screen.getByText(/Page 1/)).toBeInTheDocument() // default page
})
it('should validate route parameters', () => {
// Test with valid route param
renderWithFileRoutes(<div />, {
initialLocation: '/posts/123',
})
expect(screen.getByText(/Post 123/)).toBeInTheDocument()
})
})
파일 라우트 오류 경계 테스트
1. 라우트 수준 오류 처리 테스트
import { describe, it, expect, vi } from 'vitest'
import { screen } from '@testing-library/react'
import { renderWithFileRoutes } from '../test/file-route-utils'
describe('File Route Error Handling', () => {
it('should handle component errors with error boundary', () => {
// Mock console.error to avoid noise in test output
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// Force an error in a route component
vi.mock('../routes/error-prone.tsx', () => ({
Route: {
component: () => {
throw new Error('Test error')
},
},
}))
renderWithFileRoutes(<div />, {
initialLocation: '/error-prone',
})
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument()
consoleSpy.mockRestore()
})
it('should handle loader errors with error component', async () => {
const mockFailingLoader = vi
.fn()
.mockRejectedValue(new Error('Load failed'))
vi.mock('../api/data', () => ({
loadData: mockFailingLoader,
}))
renderWithFileRoutes(<div />, {
initialLocation: '/data-route',
})
expect(screen.getByText(/Failed to load data/)).toBeInTheDocument()
})
})
생성된 라우트 타입을 사용한 테스트
1. 타입 안전성 테스트
import { describe, it, expect } from 'vitest'
import { useNavigate } from '@tanstack/react-router'
import { renderWithFileRoutes } from '../test/file-route-utils'
describe('Generated Route Types', () => {
it('should provide type-safe navigation', () => {
function TestComponent() {
const navigate = useNavigate()
const handleNavigate = () => {
// This should be type-safe based on your generated routes
navigate({
to: '/posts/$postId',
params: { postId: '123' },
search: { tab: 'comments' },
})
}
return (
<button onClick={handleNavigate} data-testid="navigate-btn">
Navigate
</button>
)
}
const { router } = renderWithFileRoutes(<TestComponent />, {
initialLocation: '/',
})
// Test the navigation works correctly
const button = screen.getByTestId('navigate-btn')
fireEvent.click(button)
expect(router.state.location.pathname).toBe('/posts/123')
expect(router.state.location.search).toEqual({ tab: 'comments' })
})
})
라우트 트리 변경 테스트
1. 개발 중 라우트 생성 테스트
import { describe, it, expect } from 'vitest'
import { routeTree } from '../routeTree.gen'
describe('Route Tree Development', () => {
it('should regenerate routes when files change', () => {
// This test ensures your route tree is properly generated
// You can add specific assertions based on your file structure
expect(routeTree).toBeDefined()
expect(typeof routeTree.children).toBe('object')
// Test specific routes exist
const routes = getAllRouteIds(routeTree)
expect(routes).toContain('/')
expect(routes).toContain('/about')
// Add assertions for your specific routes
})
// Helper function to get all route IDs from tree
function getAllRouteIds(tree: any, ids: string[] = []): string[] {
if (tree.id) {
ids.push(tree.id)
}
if (tree.children) {
Object.values(tree.children).forEach((child: any) => {
getAllRouteIds(child, ids)
})
}
return ids
}
})
파일 기반 라우트의 E2E 테스트
1. 파일 기반 라우트를 위한 Playwright 구성
e2e/file-routing.spec.ts를 만듭니다.
import { test, expect } from '@playwright/test'
test.describe('File-Based Route E2E', () => {
test('should navigate through file-based route structure', async ({
page,
}) => {
await page.goto('/')
// Test home route (from src/routes/index.tsx)
await expect(page.locator('h3')).toContainText('Welcome Home!')
// Navigate to about route (from src/routes/about.tsx)
await page.click('text=About')
await expect(page).toHaveURL('/about')
await expect(page.locator('h3')).toContainText('About')
// Test browser navigation
await page.goBack()
await expect(page).toHaveURL('/')
})
test('should handle dynamic routes from file structure', async ({ page }) => {
await page.goto('/posts')
// Click on a dynamic post link (from src/routes/posts/$postId.tsx)
await page.click('[data-testid="post-link-1"]')
await expect(page).toHaveURL('/posts/1')
await expect(page.locator('h1')).toContainText('Post 1')
})
test('should handle nested routes', async ({ page }) => {
await page.goto('/dashboard')
// Navigate to nested route (from src/routes/dashboard/settings.tsx)
await page.click('text=Settings')
await expect(page).toHaveURL('/dashboard/settings')
await expect(page.locator('h2')).toContainText('Settings')
})
})
일반적인 파일 기반 라우팅 테스트 패턴
1. 테스트를 위한 라우트 파일 모킹
// src/test/mock-file-routes.tsx
import { createFileRoute } from '@tanstack/react-router'
// Mock individual route for isolated testing
export const createMockFileRoute = (
path: string,
component: React.ComponentType,
options: any = {},
) => {
return createFileRoute(path)({
component,
...options,
})
}
// Common test route components
export const TestHomeRoute = createMockFileRoute('/', () => (
<div data-testid="home">Home Page</div>
))
export const TestAboutRoute = createMockFileRoute('/about', () => (
<div data-testid="about">About Page</div>
))
export const TestDynamicRoute = createMockFileRoute('/posts/$postId', () => {
const { postId } = Route.useParams()
return <div data-testid="post">Post {postId}</div>
})
2. 라우트 검색 테스트
import { describe, it, expect } from 'vitest'
describe('Route Discovery', () => {
it('should discover all routes from file structure', () => {
// Test that your route tree includes all expected routes
// This helps catch when routes are accidentally not being generated
const expectedRoutes = [
'/',
'/about',
'/posts',
'/posts/$postId',
'/dashboard',
'/dashboard/settings',
]
expectedRoutes.forEach((routePath) => {
const routeExists = checkRouteExists(routeTree, routePath)
expect(routeExists).toBe(true)
})
})
})
function checkRouteExists(tree: any, path: string): boolean {
// Implementation to check if route exists in tree
// This depends on your route tree structure
return true // Simplified
}
파일 기반 라우트 테스트 모범 사례
1. 테스트 구성
src/
├── routes/
│ ├── __root.tsx
│ ├── index.tsx
│ ├── about.tsx
│ ├── posts/
│ │ ├── index.tsx
│ │ └── $postId.tsx
├── test/
│ ├── setup.ts
│ ├── file-route-utils.tsx
│ └── routes/
│ ├── index.test.tsx
│ ├── about.test.tsx
│ └── posts/
│ ├── index.test.tsx
│ └── $postId.test.tsx
2. 일반적인 테스트 패턴
// Test file for each route file
describe('Posts Route (/posts)', () => {
it('should render posts list', () => {
renderWithFileRoutes(<div />, {
initialLocation: '/posts',
})
expect(screen.getByText(/Posts/)).toBeInTheDocument()
})
it('should handle loading state', () => {
// Test pending state for route with loader
})
it('should handle error state', () => {
// Test error handling for route
})
})
// Test route groups
describe('Dashboard Routes', () => {
describe('/dashboard', () => {
// Dashboard index tests
})
describe('/dashboard/settings', () => {
// Settings route tests
})
})
파일 기반 라우트 테스트 문제 해결
일반적인 문제
문제: 테스트에서 라우트 트리를 찾을 수 없습니다.
Error: Cannot find module '../routeTree.gen'
해결 방법: 테스트 설정에서 라우트 트리가 생성되는지 확인합니다.
// vitest.config.ts
export default defineConfig({
plugins: [
tanstackRouter(), // Ensure this runs before tests
react(),
],
test: {
setupFiles: ['./src/test/setup.ts'],
},
})
문제: 파일을 변경한 후 테스트에서 라우트가 업데이트되지 않습니다.
해결 방법: 테스트 설정에서 모듈 캐시를 지웁니다.
// src/test/setup.ts
beforeEach(() => {
vi.clearAllMocks()
// Clear route tree cache if needed
delete require.cache[require.resolve('../routeTree.gen')]
})
문제: 생성된 라우트를 사용하는 테스트에서 타입 오류가 발생합니다.
해결 방법: 올바른 TypeScript 구성이 적용되었는지 확인합니다.
{
"compilerOptions": {
"types": ["vitest/globals", "@testing-library/jest-dom"],
"moduleResolution": "bundler"
},
"include": ["src/**/*", "src/routeTree.gen.ts"]
}
다음 단계
파일 기반 라우트 테스트를 설정한 후에는 다음 작업을 고려할 수 있습니다.
- 코드 기반 라우팅으로 테스트를 설정하는 방법 - 수동으로 정의한 라우트의 테스트 패턴
- 라우터 문제를 디버깅하는 방법 - 파일 기반 라우팅 문제 디버깅
- 파일 기반 라우팅 가이드 - 파일 기반 라우팅 자세히 알아보기
관련 리소스
- TanStack Router 파일 기반 라우팅 - 파일 기반 라우팅 전체 가이드
- 파일 이름 규칙 - 파일 구조 이해
- Testing Library - 컴포넌트 테스트 유틸리티
- Vitest - 테스트 프레임워크 문서