2026년 React 테스트: Vitest, React Testing Library와 모범 사례
2026년 최신 React 테스트 방법론을 상세히 설명합니다. Vitest, React Testing Library 활용법, 컴포넌트 테스트, 통합 테스트, 기술 면접 대비까지 종합적으로 다룹니다.

2026년 현재, React 애플리케이션 테스트는 개발 프로세스에서 필수적인 요소가 되었습니다. Vitest의 등장으로 빠르고 설정이 간편한 테스트 환경이 구현되었으며, React Testing Library(RTL)와의 조합을 통해 사용자 관점의 테스트가 표준적인 방법론으로 자리 잡았습니다. 본 문서에서는 최신 React 테스트 기술, 모범 사례, 그리고 기술 면접에서 자주 출제되는 질문에 대해 상세히 설명합니다.
Vitest는 Vite 기반의 고속 테스트 프레임워크입니다. Jest와 호환되는 API를 제공하면서도 ES 모듈 네이티브 지원, 핫 모듈 교체(HMR)를 통한 워치 모드 고속화, TypeScript 무설정 지원 등 현대적인 개발 경험을 제공합니다. 2026년 React 프로젝트에서 Vitest는 사실상 표준 테스트 프레임워크로 자리 잡았습니다.
Vitest 환경 구성과 React Testing Library 통합
Vitest와 React Testing Library를 조합하면 빠르고 신뢰성 높은 테스트 환경을 구축할 수 있습니다. 먼저 필요한 패키지를 설치합니다.
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomVitest 설정 파일에서 React 환경과 전역 테스트 유틸리티를 설정합니다.
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
})셋업 파일에서는 jest-dom 매처를 확장하고 테스트 간 클린업을 설정합니다.
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(() => {
cleanup()
})
// Mock window.matchMedia for responsive components
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})이 설정을 통해 각 테스트 파일에서 describe, it, expect 등의 전역 함수를 사용할 수 있으며, 각 테스트 후 자동으로 DOM이 클린업됩니다.
컴포넌트 테스트의 기본 패턴
React Testing Library의 핵심 철학은 "사용자가 컴포넌트를 사용하는 방식으로 테스트한다"는 것입니다. 구현 세부사항이 아닌 사용자 관점에서 컴포넌트의 동작을 검증합니다.
기본적인 컴포넌트 테스트 예제를 살펴봅니다.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { Button } from './Button'
describe('Button', () => {
it('renders with correct text', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument()
})
it('calls onClick handler when clicked', async () => {
const user = userEvent.setup()
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Submit</Button>)
await user.click(screen.getByRole('button', { name: /submit/i }))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('is disabled when loading', () => {
render(<Button isLoading>Loading...</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
it('shows spinner when loading', () => {
render(<Button isLoading>Submit</Button>)
expect(screen.getByRole('button')).toContainElement(
screen.getByTestId('spinner')
)
})
})screen.getByRole을 사용하면 접근성 관점에서도 올바른 마크업을 촉진합니다. userEvent는 브라우저에서의 실제 사용자 조작을 더 정확하게 시뮬레이션하므로 fireEvent보다 권장됩니다.
비동기 처리와 데이터 페칭 테스트
React 애플리케이션에서는 API로부터의 데이터 페칭 등 비동기 처리가 빈번하게 발생합니다. 이러한 테스트에는 waitFor나 findBy 쿼리를 사용합니다.
import { render, screen, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { UserProfile } from './UserProfile'
// Mock the fetch API
const mockUser = {
id: '1',
name: 'John Doe',
email: 'john@example.com',
}
beforeEach(() => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => mockUser,
} as Response)
})
describe('UserProfile', () => {
it('displays loading state initially', () => {
render(<UserProfile userId="1" />)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
it('displays user data after fetch', async () => {
render(<UserProfile userId="1" />)
// Wait for the user name to appear
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
it('displays error message on fetch failure', async () => {
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/error/i)
})
})
})findBy 쿼리는 내부적으로 waitFor를 사용하므로 비동기 요소 대기에 편리합니다.
it('displays user data after fetch', async () => {
render(<UserProfile userId="1" />)
// findBy automatically waits for the element
const userName = await screen.findByText('John Doe')
expect(userName).toBeInTheDocument()
})폼 테스트와 사용자 인터랙션
폼 테스트는 유효성 검사, 제출, 에러 표시 등 여러 측면을 검증해야 합니다.
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { LoginForm } from './LoginForm'
describe('LoginForm', () => {
it('submits form with valid data', async () => {
const user = userEvent.setup()
const handleSubmit = vi.fn()
render(<LoginForm onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
})
})
})
it('displays validation errors for invalid email', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'invalid-email')
await user.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => {
expect(screen.getByText(/valid email/i)).toBeInTheDocument()
})
})
it('disables submit button while submitting', async () => {
const user = userEvent.setup()
const handleSubmit = vi.fn(() => new Promise((r) => setTimeout(r, 100)))
render(<LoginForm onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(screen.getByRole('button', { name: /sign in/i })).toBeDisabled()
})
})userEvent.type은 실제 키보드 입력을 시뮬레이션하며 각 키 입력마다 이벤트를 발생시킵니다. 이를 통해 입력 중 유효성 검사나 디바운스 처리도 올바르게 테스트할 수 있습니다.
React / Next.js 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
커스텀 훅 테스트
커스텀 훅 테스트에는 @testing-library/react의 renderHook을 사용합니다.
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('initializes with provided value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('increments counter', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('decrements counter', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
it('resets counter to initial value', () => {
const { result } = renderHook(() => useCounter(10))
act(() => {
result.current.increment()
result.current.increment()
result.current.reset()
})
expect(result.current.count).toBe(10)
})
})상태를 변경하는 작업은 반드시 act로 래핑합니다. 이를 통해 React의 상태 업데이트가 완료된 후 어설션이 실행됩니다.
컨텍스트와 프로바이더 테스트
컨텍스트를 사용하는 컴포넌트를 테스트하려면 테스트용 래퍼를 생성합니다.
import { ReactNode } from 'react'
import { render, RenderOptions } from '@testing-library/react'
import { ThemeProvider } from '@/contexts/ThemeContext'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
})
interface WrapperProps {
children: ReactNode
}
function AllProviders({ children }: WrapperProps) {
const queryClient = createTestQueryClient()
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="light">{children}</ThemeProvider>
</QueryClientProvider>
)
}
const customRender = (
ui: React.ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { wrapper: AllProviders, ...options })
export * from '@testing-library/react'
export { customRender as render }이 커스텀 render 함수를 사용하면 모든 테스트에서 일관된 프로바이더 구성을 유지할 수 있습니다.
모킹과 스파이의 효과적인 활용
Vitest에서는 vi.mock과 vi.spyOn을 사용하여 모듈이나 함수를 모킹할 수 있습니다.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fetchUsers, createUser } from './api'
// Mock the entire module
vi.mock('./http-client', () => ({
httpClient: {
get: vi.fn(),
post: vi.fn(),
},
}))
import { httpClient } from './http-client'
describe('API Service', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches users successfully', async () => {
const mockUsers = [{ id: '1', name: 'John' }]
vi.mocked(httpClient.get).mockResolvedValue({ data: mockUsers })
const users = await fetchUsers()
expect(httpClient.get).toHaveBeenCalledWith('/users')
expect(users).toEqual(mockUsers)
})
it('creates user with correct payload', async () => {
const newUser = { name: 'Jane', email: 'jane@example.com' }
vi.mocked(httpClient.post).mockResolvedValue({ data: { id: '2', ...newUser } })
await createUser(newUser)
expect(httpClient.post).toHaveBeenCalledWith('/users', newUser)
})
})vi.mocked는 타입 안전한 모킹을 제공하며 TypeScript의 자동 완성 기능을 활용할 수 있습니다.
React Testing 면접에서 자주 나오는 질문
기술 면접에서는 React 테스트에 관한 깊은 이해가 요구됩니다. 다음은 자주 출제되는 질문과 그 답변입니다.
Q1: getBy, queryBy, findBy의 차이점을 설명해 주십시오.
getBy는 요소가 존재한다고 가정하며, 찾지 못하면 에러를 발생시킵니다. 동기적으로 동작하며 요소가 확실히 존재하는 경우에 사용합니다. queryBy는 요소를 찾지 못하면 null을 반환합니다. 요소가 존재하지 않음을 어설션하는 경우에 사용합니다. findBy는 비동기로 동작하며 요소가 나타날 때까지 대기합니다. 기본적으로 1000ms의 타임아웃이 있으며, 비동기 렌더링이나 데이터 페칭 후의 요소에 사용합니다.
Q2: 왜 fireEvent보다 userEvent가 권장됩니까?
userEvent는 실제 브라우저에서의 사용자 조작을 더 정확하게 시뮬레이션합니다. 예를 들어 userEvent.type은 키보드 이벤트(keydown, keypress, keyup)와 입력 이벤트를 모두 발생시키며 포커스 관리도 수행합니다. 반면 fireEvent는 단일 이벤트만 발생시키므로 실제 사용자 조작과 다른 동작이 될 수 있습니다. userEvent를 사용하면 더 현실적인 테스트 시나리오를 작성할 수 있습니다.
Q3: 스냅샷 테스트의 장점과 단점은 무엇입니까?
장점으로는 UI의 예기치 않은 변경을 감지할 수 있고, 설정이 간단하며 큰 컴포넌트 트리도 빠르게 테스트할 수 있습니다. 단점으로는 자주 변경되는 컴포넌트에서는 유지보수 비용이 높고, 실제 동작이 아닌 출력 구조만 테스트하므로 중요한 버그를 놓칠 수 있습니다. 또한 개발자가 스냅샷을 무비판적으로 업데이트하는 경향이 있습니다. 모범 사례로서 스냅샷 테스트는 정적 UI 컴포넌트에 한정하고, 인터랙티브한 기능은 사용자 이벤트 기반 테스트로 검증하는 것이 권장됩니다.
Q4: 테스트 커버리지 100%를 목표로 해야 합니까?
100% 커버리지가 반드시 고품질 테스트를 의미하지는 않습니다. 중요한 것은 비즈니스 크리티컬한 코드 경로가 충분히 테스트되어 있는지입니다. 커버리지는 지표 중 하나일 뿐이며, 테스트의 품질이나 의미 있는 어설션의 존재를 보장하지 않습니다. 일반적으로 80% 정도의 커버리지를 목표로 하고, 나머지 20%는 복잡성이나 리스크에 따라 판단합니다. 유틸리티 함수나 비즈니스 로직에는 높은 커버리지를 유지하고, UI 컴포넌트는 사용자 인터랙션에 초점을 맞춘 테스트를 우선시하는 것이 효과적입니다.
Q5: 테스트 피라미드와 테스트 트로피의 차이점은 무엇입니까?
테스트 피라미드는 유닛 테스트를 많이, 통합 테스트를 중간 정도, E2E 테스트를 적게 배치하는 전통적인 접근 방식입니다. 테스트 트로피는 React 애플리케이션에 적합한 현대적인 접근 방식으로, 통합 테스트를 가장 중시합니다. 이는 컴포넌트 간의 상호작용을 테스트함으로써 유닛 테스트보다 높은 신뢰성을 얻을 수 있기 때문입니다. React Testing Library의 제작자인 Kent C. Dodds가 제창했으며, "테스트가 소프트웨어 사용 방식과 유사할수록 더 많은 신뢰를 얻을 수 있다"는 원칙에 기반합니다.
통합 테스트 모범 사례
통합 테스트에서는 여러 컴포넌트가 연계하여 동작하는지 검증합니다.
import { render, screen, waitFor } from '@/test/utils'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { CheckoutPage } from './CheckoutPage'
import { server } from '@/test/mocks/server'
import { http, HttpResponse } from 'msw'
describe('Checkout Flow', () => {
it('completes checkout successfully', async () => {
const user = userEvent.setup()
render(<CheckoutPage />)
// Fill shipping information
await user.type(screen.getByLabelText(/full name/i), 'John Doe')
await user.type(screen.getByLabelText(/address/i), '123 Main St')
await user.type(screen.getByLabelText(/city/i), 'New York')
await user.click(screen.getByRole('button', { name: /continue to payment/i }))
// Fill payment information
await user.type(screen.getByLabelText(/card number/i), '4242424242424242')
await user.type(screen.getByLabelText(/expiry/i), '12/28')
await user.type(screen.getByLabelText(/cvc/i), '123')
await user.click(screen.getByRole('button', { name: /place order/i }))
// Verify success state
await waitFor(() => {
expect(screen.getByText(/order confirmed/i)).toBeInTheDocument()
})
})
it('handles payment failure gracefully', async () => {
// Override the default handler for this test
server.use(
http.post('/api/payments', () => {
return HttpResponse.json(
{ error: 'Payment declined' },
{ status: 400 }
)
})
)
const user = userEvent.setup()
render(<CheckoutPage />)
// ... fill form and submit ...
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/payment declined/i)
})
})
})MSW(Mock Service Worker)를 사용하면 네트워크 레벨에서 API를 모킹하여 더 현실적인 통합 테스트를 구현할 수 있습니다.
테스트 성능 최적화
대규모 테스트 스위트에서는 실행 시간 최적화가 중요합니다.
export default defineConfig({
test: {
// Run tests in parallel
pool: 'threads',
poolOptions: {
threads: {
singleThread: false,
},
},
// Only run affected tests in watch mode
watch: {
include: ['src/**/*.{ts,tsx}'],
},
// Isolate test files for reliability
isolate: true,
},
})또한 무거운 셋업은 beforeAll에서 한 번만 실행하고, beforeEach에서의 재초기화를 최소한으로 줄입니다.
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
결론: 신뢰성 높은 React 테스트를 작성하기 위해
2026년 React 테스트에서 Vitest와 React Testing Library의 조합은 빠르고 신뢰성 높은 테스트 환경을 제공합니다.
본 문서에서 학습한 주요 포인트:
- 사용자 관점에서 테스트: 구현 세부사항이 아닌 사용자가 컴포넌트를 어떻게 사용하는지에 초점
- 적절한 쿼리 선택:
getBy,queryBy,findBy를 상황에 맞게 구분하여 사용 userEvent우선 사용: 더 현실적인 사용자 인터랙션 시뮬레이션- 통합 테스트 중시: 테스트 트로피 개념에 따라 컴포넌트 간 연계를 테스트
- 모킹의 적절한 사용: 외부 의존성은 모킹하고 내부 구현은 가능한 한 실제 코드 사용
React 테스트 스킬은 코드 품질을 보장할 뿐만 아니라 리팩토링의 안전성을 높이고 팀 전체의 개발 속도를 향상시킵니다. 기술 면접에서도 테스트에 관한 깊은 이해는 후보자의 실무 개발 역량을 보여주는 중요한 지표가 됩니다.
태그
공유
관련 기사

React Compiler 2026: 자동 메모이제이션의 원리와 기술 면접 완벽 가이드
React Compiler v1.0의 자동 메모이제이션 내부 구조, 컴파일 파이프라인, 수동 최적화가 필요한 시나리오를 상세히 분석합니다. 2026년 React 기술 면접에서 자주 출제되는 핵심 질문을 포괄적으로 다룹니다.

Next.js 16 Server Actions 2026: 뮤테이션, 무효화 그리고 면접 질문
Next.js 16 Server Action이 뮤테이션, 무효화, 대기 상태, 낙관적 UI, 보안을 어떻게 처리하는지, 그리고 각 개념을 검증하는 면접 질문까지 살펴봅니다.

React 19 useEffectEvent와 Activity 완벽 가이드: 새로운 API와 면접 질문 2026
React 19.2에서 도입된 useEffectEvent와 Activity 컴포넌트의 동작 원리, 실전 패턴, 면접 핵심 질문을 체계적으로 다룹니다.