
Next.js 프로젝트에서 Jest를 활용한 유닛·통합 테스트 완전 가이드
핵심 요약
Next.js 환경에서 React Query, Zod, Zustand, Radix UI와 같은 최신 라이브러리들을 사용하는 프로젝트에서는 유닛 테스트와 통합 테스트를 층별로 분리하고, 각 라이브러리의 특성에 맞는 모킹 전략을 적용하여 신뢰성 높은 테스트 환경을 구축해야 한다.
1. 기본 환경 설정
필수 패키지 설치
# Jest 및 테스팅 라이브러리 설치
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
# TypeScript 지원
npm install -D ts-jest @types/jest
# API 통합 테스트용 (선택사항)
npm install -D supertest @types/supertest next-test-api-route-handler
Jest 설정 파일 (jest.config.ts
)
import type { Config } from 'jest'
import nextJest from 'next/jest.js'
const createJestConfig = nextJest({
dir: './',
})
const config: Config = {
coverageProvider: 'v8',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
moduleNameMapper: {
'@/(.*)$': '<rootDir>/src/$1',
},
// 테스트 유형별 분리
projects: [
{
displayName: 'unit',
testMatch: ['<rootDir>/**/*.unit.test.{ts,tsx}'],
testEnvironment: 'jsdom',
},
{
displayName: 'integration',
testMatch: ['<rootDir>/**/*.integration.test.{ts,tsx}'],
testEnvironment: 'jsdom',
},
{
displayName: 'api',
testMatch: ['<rootDir>/src/pages/api/**/*.test.{ts,tsx}', '<rootDir>/src/app/api/**/*.test.{ts,tsx}'],
testEnvironment: 'node', // API 테스트는 Node 환경
}
]
}
export default createJestConfig(config)
테스트 초기화 파일 (jest.setup.ts
)
import '@testing-library/jest-dom'
// React Query 테스트 설정
import { cleanup } from '@testing-library/react'
import { afterEach } from '@jest/globals'
afterEach(() => {
cleanup()
})
// Zustand 초기화
beforeEach(() => {
// Zustand 스토어 초기화 로직
})
// Radix UI 테스트용 모킹
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
})
// Radix UI 포인터 이벤트 모킹
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}))
2. React Query 테스트 설정
테스트 유틸리티 (test-utils.tsx
)
import React, { ReactElement } from 'react'
import { render, RenderOptions } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false, // 테스트에서는 재시도 비활성화
cacheTime: Infinity,
},
mutations: {
retry: false,
},
},
})
interface AllProvidersProps {
children: React.ReactNode
}
const AllProviders = ({ children }: AllProvidersProps) => {
const testQueryClient = createTestQueryClient()
return (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
)
}
const customRender = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>,
) => render(ui, { wrapper: AllProviders, ...options })
export * from '@testing-library/react'
export { customRender as render }
React Query 훅 테스트 예시
// hooks/useUser.integration.test.tsx
import { renderHook, waitFor } from '@testing-library/react'
import { useUser } from '@/hooks/useUser'
import { createTestQueryClient } from '@/test-utils'
describe('useUser Integration', () => {
it('should fetch user data successfully', async () => {
// API 모킹
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: 'John Doe' }),
})
const { result } = renderHook(() => useUser(1), {
wrapper: ({ children }) => (
<QueryClientProvider client={createTestQueryClient()}>
{children}
</QueryClientProvider>
),
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual({ id: 1, name: 'John Doe' })
})
})
3. Zustand 스토어 테스트
Zustand 모킹 설정 (__mocks__/zustand.ts
)
import { act } from '@testing-library/react'
const { create: actualCreate } = jest.requireActual('zustand')
// 스토어 상태 초기화 유틸리티
export const storeResetFns = new Set<() => void>()
const create = (createState: any) => {
const store = actualCreate(createState)
const initialState = store.getState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}
export { create }
Zustand 스토어 단위 테스트
// stores/userStore.unit.test.ts
import { renderHook, act } from '@testing-library/react'
import { useUserStore } from '@/stores/userStore'
// Zustand 모킹
jest.mock('zustand', () => require('@/__mocks__/zustand'))
describe('UserStore Unit Tests', () => {
beforeEach(() => {
// 각 테스트 전 스토어 상태 초기화
const { storeResetFns } = require('@/__mocks__/zustand')
storeResetFns.forEach((resetFn: () => void) => resetFn())
})
it('should update user data', () => {
const { result } = renderHook(() => useUserStore())
act(() => {
result.current.setUser({ id: 1, name: 'Alice' })
})
expect(result.current.user).toEqual({ id: 1, name: 'Alice' })
})
})
Zustand와 컴포넌트 통합 테스트
// components/UserProfile.integration.test.tsx
import { render, screen, fireEvent } from '@/test-utils'
import UserProfile from '@/components/UserProfile'
// Zustand 스토어 특정 모킹
jest.mock('@/stores/userStore', () => ({
useUserStore: (selector: any) => {
const mockStore = {
user: { id: 1, name: 'Test User' },
setUser: jest.fn(),
loading: false,
}
return selector(mockStore)
}
}))
describe('UserProfile Integration', () => {
it('should display user information and handle updates', () => {
render(<UserProfile />)
expect(screen.getByText('Test User')).toBeInTheDocument()
// 사용자 상호작용 테스트
const editButton = screen.getByRole('button', { name: /edit/i })
fireEvent.click(editButton)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
4. Zod 스키마 테스트
Zod 스키마 단위 테스트
// schemas/userSchema.unit.test.ts
import { userSchema } from '@/schemas/userSchema'
describe('User Schema Validation', () => {
it('should validate correct user data', () => {
const validUser = {
name: 'John Doe',
email: 'john@example.com',
age: 25
}
const result = userSchema.safeParse(validUser)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data).toEqual(validUser)
}
})
it('should reject invalid email format', () => {
const invalidUser = {
name: 'John Doe',
email: 'invalid-email',
age: 25
}
const result = userSchema.safeParse(invalidUser)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues).toContainEqual(
expect.objectContaining({
path: ['email'],
code: 'invalid_string'
})
)
}
})
it('should handle schema transformations', () => {
const dateSchema = z.string().transform(str => new Date(str))
const result = dateSchema.safeParse('2023-01-01')
expect(result.success).toBe(true)
if (result.success) {
expect(result.data instanceof Date).toBe(true)
}
})
})
폼 검증과 Zod 통합 테스트
// components/UserForm.integration.test.tsx
import { render, screen, fireEvent, waitFor } from '@/test-utils'
import UserForm from '@/components/UserForm'
import userEvent from '@testing-library/user-event'
describe('UserForm with Zod Validation', () => {
it('should show validation errors for invalid input', async () => {
const user = userEvent.setup()
render(<UserForm />)
const emailInput = screen.getByLabelText(/email/i)
const submitButton = screen.getByRole('button', { name: /submit/i })
// 잘못된 이메일 입력
await user.type(emailInput, 'invalid-email')
await user.click(submitButton)
await waitFor(() => {
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument()
})
})
})
5. Radix UI 컴포넌트 테스트
Radix UI 컴포넌트 모킹 설정
// __mocks__/radix-ui.ts
export const mockRadixSelect = {
Root: ({ children }: { children: React.ReactNode }) => <div data-testid="select-root">{children}</div>,
Trigger: ({ children, ...props }: any) => (
<button data-testid="select-trigger" {...props}>{children}</button>
),
Content: ({ children }: { children: React.ReactNode }) => (
<div data-testid="select-content">{children}</div>
),
Item: ({ children, value, ...props }: any) => (
<div data-testid="select-item" data-value={value} {...props}>{children}</div>
),
}
jest.mock('@radix-ui/react-select', () => mockRadixSelect)
Radix UI 통합 테스트
// components/CustomSelect.integration.test.tsx
import { render, screen, fireEvent } from '@/test-utils'
import CustomSelect from '@/components/CustomSelect'
// Radix UI 포인터 이벤트 모킹
beforeAll(() => {
window.HTMLElement.prototype.scrollIntoView = jest.fn()
window.HTMLElement.prototype.releasePointerCapture = jest.fn()
window.HTMLElement.prototype.hasPointerCapture = jest.fn()
})
describe('CustomSelect Integration', () => {
it('should handle select interactions', async () => {
const onValueChange = jest.fn()
render(
<CustomSelect
options={[{ value: 'option1', label: 'Option 1' }]}
onValueChange={onValueChange}
/>
)
const trigger = screen.getByTestId('select-trigger')
// 포인터 이벤트로 상호작용 시뮬레이션
fireEvent.pointerDown(trigger)
fireEvent.pointerUp(trigger)
const option = screen.getByTestId('select-item')
fireEvent.click(option)
expect(onValueChange).toHaveBeenCalledWith('option1')
})
})
6. Next.js API Routes 테스트
App Router API 테스트
// app/api/users/route.test.ts
/**
* @jest-environment node
*/
import { GET, POST } from './route'
import { NextRequest } from 'next/server'
describe('/api/users', () => {
describe('GET', () => {
it('should return users list', async () => {
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(Array.isArray(data)).toBe(true)
})
})
describe('POST', () => {
it('should create new user', async () => {
const requestBody = { name: 'John Doe', email: 'john@example.com' }
const request = new NextRequest('http://localhost:3000/api/users', {
method: 'POST',
body: JSON.stringify(requestBody),
headers: { 'Content-Type': 'application/json' }
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(201)
expect(data).toMatchObject(requestBody)
})
})
})
API 통합 테스트 (Supertest 활용)
// api/integration/users.integration.test.ts
import request from 'supertest'
import { createServer } from 'http'
import { NextApiHandler } from 'next'
import handler from '@/pages/api/users'
const server = createServer((req, res) => {
return handler(req as any, res as any)
})
describe('/api/users Integration', () => {
it('should handle full user workflow', async () => {
// 사용자 생성
const createResponse = await request(server)
.post('/api/users')
.send({ name: 'Alice', email: 'alice@example.com' })
.expect(201)
const userId = createResponse.body.id
// 사용자 조회
await request(server)
.get(`/api/users/${userId}`)
.expect(200)
.expect(res => {
expect(res.body.name).toBe('Alice')
})
})
})
7. 통합 테스트 시나리오 예시
전체 워크플로우 통합 테스트
// integration/userWorkflow.integration.test.tsx
import { render, screen, waitFor } from '@/test-utils'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import UserManagementPage from '@/pages/users'
import userEvent from '@testing-library/user-event'
describe('User Management Workflow', () => {
let queryClient: QueryClient
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
})
// API 모킹
global.fetch = jest.fn()
})
it('should complete full user management flow', async () => {
const user = userEvent.setup()
// API 응답 모킹
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ users: [] })
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 1, name: 'New User', email: 'new@example.com' })
})
render(
<QueryClientProvider client={queryClient}>
<UserManagementPage />
</QueryClientProvider>
)
// 초기 로딩 확인
expect(screen.getByText(/loading/i)).toBeInTheDocument()
// 사용자 추가 폼 작성
await waitFor(() => {
expect(screen.getByRole('button', { name: /add user/i })).toBeInTheDocument()
})
const addButton = screen.getByRole('button', { name: /add user/i })
await user.click(addButton)
const nameInput = screen.getByLabelText(/name/i)
const emailInput = screen.getByLabelText(/email/i)
const submitButton = screen.getByRole('button', { name: /submit/i })
await user.type(nameInput, 'New User')
await user.type(emailInput, 'new@example.com')
await user.click(submitButton)
// 성공 메시지 확인
await waitFor(() => {
expect(screen.getByText(/user created successfully/i)).toBeInTheDocument()
})
})
})
8. 테스트 실행 스크립트
package.json
설정
{
"scripts": {
"test": "jest",
"test:unit": "jest --selectProjects unit",
"test:integration": "jest --selectProjects integration",
"test:api": "jest --selectProjects api",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --watchAll=false"
}
}
9. 모범 사례 및 주의사항
테스트 분리 전략
- 유닛 테스트: 개별 함수, 훅, 컴포넌트의 독립적 동작 검증
- 통합 테스트: 여러 컴포넌트·서비스가 연동되는 워크플로우 검증
- API 테스트: 서버 엔드포인트의 요청·응답 사이클 검증
성능 최적화
- 테스트 병렬 실행: Jest의
--maxWorkers
옵션 활용 - 스마트 모킹: 필요한 부분만 모킹하여 테스트 신뢰성 유지
- 캐시 관리: React Query 캐시 무효화로 테스트 간 격리 보장
지속적 통합
- GitHub Actions/Jenkins: 자동화된 테스트 파이프라인 구축
- 코드 커버리지: 80% 이상 목표, 핵심 로직 우선 커버
- 테스트 안정성: Flaky 테스트 최소화를 위한 적절한 대기시간 설정
10. 결론
Next.js 환경에서 현대적인 라이브러리 스택을 활용한 프로젝트의 테스트는 각 도구의 특성을 이해하고 적절한 모킹 전략을 적용하는 것이 핵심이다. 유닛 테스트로 개별 로직을 검증하고, 통합 테스트로 사용자 시나리오를 보장하며, API 테스트로 서버 로직을 확인함으로써 신뢰할 수 있는 애플리케이션을 구축할 수 있다. 지속적인 자동화와 적절한 테스트 커버리지를 통해 장기적으로 유지보수 가능한 고품질 코드베이스를 만들어 나가는 것이 중요하다123456.
Footnotes
-
https://dev.to/dforrunner/how-to-unit-test-nextjs-13-app-router-api-routes-with-jest-and-react-testing-library-270a ↩
-
https://gist.github.com/abdmmar/1936d6ff82396cbc87b505b83608bc64 ↩
-
https://tanstack.com/query/latest/docs/react/guides/testing ↩
-
https://stackoverflow.com/questions/74861753/mocking-zustand-store-for-jest-test ↩
-
https://app.studyraid.com/en/read/11289/352212/testing-schema-implementations ↩