# 2026년 React 테스트: Vitest, React Testing Library와 모범 사례 > 2026년 최신 React 테스트 방법론을 상세히 설명합니다. Vitest, React Testing Library 활용법, 컴포넌트 테스트, 통합 테스트, 기술 면접 대비까지 종합적으로 다룹니다. - Published: 2026-07-09 - Updated: 2026-07-09 - Author: SharpSkill - Tags: react, testing, vitest, react-testing-library, frontend - Reading time: 12 min --- 2026년 현재, React 애플리케이션 테스트는 개발 프로세스에서 필수적인 요소가 되었습니다. Vitest의 등장으로 빠르고 설정이 간편한 테스트 환경이 구현되었으며, React Testing Library(RTL)와의 조합을 통해 사용자 관점의 테스트가 표준적인 방법론으로 자리 잡았습니다. 본 문서에서는 최신 React 테스트 기술, 모범 사례, 그리고 기술 면접에서 자주 출제되는 질문에 대해 상세히 설명합니다. > **왜 Vitest인가** > > Vitest는 Vite 기반의 고속 테스트 프레임워크입니다. Jest와 호환되는 API를 제공하면서도 ES 모듈 네이티브 지원, 핫 모듈 교체(HMR)를 통한 워치 모드 고속화, TypeScript 무설정 지원 등 현대적인 개발 경험을 제공합니다. 2026년 React 프로젝트에서 Vitest는 사실상 표준 테스트 프레임워크로 자리 잡았습니다. ## Vitest 환경 구성과 React Testing Library 통합 Vitest와 React Testing Library를 조합하면 빠르고 신뢰성 높은 테스트 환경을 구축할 수 있습니다. 먼저 필요한 패키지를 설치합니다. ```bash npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom ``` Vitest 설정 파일에서 React 환경과 전역 테스트 유틸리티를 설정합니다. ```typescript // vitest.config.ts 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` 매처를 확장하고 테스트 간 클린업을 설정합니다. ```typescript // src/test/setup.ts 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의 핵심 철학은 "사용자가 컴포넌트를 사용하는 방식으로 테스트한다"는 것입니다. 구현 세부사항이 아닌 사용자 관점에서 컴포넌트의 동작을 검증합니다. 기본적인 컴포넌트 테스트 예제를 살펴봅니다. ```typescript // src/components/Button.test.tsx 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() 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() await user.click(screen.getByRole('button', { name: /submit/i })) expect(handleClick).toHaveBeenCalledTimes(1) }) it('is disabled when loading', () => { render() expect(screen.getByRole('button')).toBeDisabled() }) it('shows spinner when loading', () => { render() expect(screen.getByRole('button')).toContainElement( screen.getByTestId('spinner') ) }) }) ``` `screen.getByRole`을 사용하면 접근성 관점에서도 올바른 마크업을 촉진합니다. `userEvent`는 브라우저에서의 실제 사용자 조작을 더 정확하게 시뮬레이션하므로 `fireEvent`보다 권장됩니다. ## 비동기 처리와 데이터 페칭 테스트 React 애플리케이션에서는 API로부터의 데이터 페칭 등 비동기 처리가 빈번하게 발생합니다. 이러한 테스트에는 `waitFor`나 `findBy` 쿼리를 사용합니다. ```typescript // src/components/UserProfile.test.tsx 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() expect(screen.getByText(/loading/i)).toBeInTheDocument() }) it('displays user data after fetch', async () => { render() // 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() await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent(/error/i) }) }) }) ``` `findBy` 쿼리는 내부적으로 `waitFor`를 사용하므로 비동기 요소 대기에 편리합니다. ```typescript it('displays user data after fetch', async () => { render() // findBy automatically waits for the element const userName = await screen.findByText('John Doe') expect(userName).toBeInTheDocument() }) ``` ## 폼 테스트와 사용자 인터랙션 폼 테스트는 유효성 검사, 제출, 에러 표시 등 여러 측면을 검증해야 합니다. ```typescript // src/components/LoginForm.test.tsx 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() 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() 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() 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`은 실제 키보드 입력을 시뮬레이션하며 각 키 입력마다 이벤트를 발생시킵니다. 이를 통해 입력 중 유효성 검사나 디바운스 처리도 올바르게 테스트할 수 있습니다. ## 커스텀 훅 테스트 커스텀 훅 테스트에는 `@testing-library/react`의 `renderHook`을 사용합니다. ```typescript // src/hooks/useCounter.test.ts 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의 상태 업데이트가 완료된 후 어설션이 실행됩니다. ## 컨텍스트와 프로바이더 테스트 컨텍스트를 사용하는 컴포넌트를 테스트하려면 테스트용 래퍼를 생성합니다. ```typescript // src/test/utils.tsx 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 ( {children} ) } const customRender = ( ui: React.ReactElement, options?: Omit ) => render(ui, { wrapper: AllProviders, ...options }) export * from '@testing-library/react' export { customRender as render } ``` 이 커스텀 `render` 함수를 사용하면 모든 테스트에서 일관된 프로바이더 구성을 유지할 수 있습니다. ## 모킹과 스파이의 효과적인 활용 Vitest에서는 `vi.mock`과 `vi.spyOn`을 사용하여 모듈이나 함수를 모킹할 수 있습니다. ```typescript // src/services/api.test.ts 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가 제창했으며, "테스트가 소프트웨어 사용 방식과 유사할수록 더 많은 신뢰를 얻을 수 있다"는 원칙에 기반합니다. ## 통합 테스트 모범 사례 통합 테스트에서는 여러 컴포넌트가 연계하여 동작하는지 검증합니다. ```typescript // src/features/checkout/Checkout.integration.test.tsx 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() // 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() // ... fill form and submit ... await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent(/payment declined/i) }) }) }) ``` MSW(Mock Service Worker)를 사용하면 네트워크 레벨에서 API를 모킹하여 더 현실적인 통합 테스트를 구현할 수 있습니다. ## 테스트 성능 최적화 대규모 테스트 스위트에서는 실행 시간 최적화가 중요합니다. ```typescript // vitest.config.ts 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 테스트 스킬은 코드 품질을 보장할 뿐만 아니라 리팩토링의 안전성을 높이고 팀 전체의 개발 속도를 향상시킵니다. 기술 면접에서도 테스트에 관한 깊은 이해는 후보자의 실무 개발 역량을 보여주는 중요한 지표가 됩니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/react-next/react-testing-2026-vitest-rtl-best-practices