React 교재 · 고급 21편

React 테스트 — Vitest + Testing Library

사용자가 보는 대로 테스트. 클래스명 검사 말고 "버튼을 클릭하면 메시지가 보인다".

테스트 튜브 안 UI 컴포넌트와 녹색 체크 일러스트 — 컴포넌트 테스트 컨셉

React 컴포넌트 테스트의 두 도구가 사실상 표준 — Vitest (테스트 러너, Vite 와 짝) + React Testing Library (DOM 조작·검증). 둘 다 가벼우면서 모던 React 패턴을 잘 다룬다.

이번 21편은 첫 테스트 5분 셋업 + Testing Library 의 핵심 철학 (queryByRole 우선) + 이벤트 시뮬레이션 + async 검증 + TanStack Query 같은 fetch 모킹.

1. 셋업 — Vite 프로젝트면 3분

npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom // vite.config.ts 에 test 옵션 추가 export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true, setupFiles: './src/test-setup.ts', }, }); // src/test-setup.ts import '@testing-library/jest-dom/vitest'; // package.json scripts "test": "vitest", "test:ui": "vitest --ui"

npm test 면 watch 모드, npm run test:ui 면 브라우저 UI 가 뜸 (테스트 실패한 부분 시각화).

2. 첫 테스트 — render + queryByRole

// Button.test.tsx import { render, screen } from '@testing-library/react'; import { Button } from './Button'; test('버튼이 라벨을 표시한다', () => { render(<Button label="저장" onClick={() => {}} />); const button = screen.getByRole('button', { name: '저장' }); expect(button).toBeInTheDocument(); });

Testing Library 철학 — "사용자가 보는 방식으로 요소를 찾는다". 클래스명·테스트 ID 가 아니라 role (button·link·heading) 과 접근 가능한 이름 (label·text) 으로.

왜 role 우선인가 — ① 스크린리더 사용자가 인지하는 방식과 일치 → 접근성 자동 검증. ② class 이름 바꿔도 테스트 안 깨짐. ③ 디자인 리팩토링에 강함. testid 는 "다른 방법이 없을 때" 최후 수단.

3. 이벤트 시뮬레이션 — userEvent

import { userEvent } from '@testing-library/user-event'; test('버튼 클릭 시 onClick 호출', async () => { const handleClick = vi.fn(); // Vitest mock render(<Button label="저장" onClick={handleClick} />); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: '저장' })); expect(handleClick).toHaveBeenCalledTimes(1); }); test('input 에 텍스트 입력', async () => { render(<LoginForm />); const user = userEvent.setup(); await user.type(screen.getByLabelText('이메일'), '[email protected]'); await user.type(screen.getByLabelText('비밀번호'), 'pwd1234'); await user.click(screen.getByRole('button', { name: '로그인' })); // 검증 expect(screen.getByText('로그인 중...')).toBeInTheDocument(); });

userEvent 는 실제 사용자처럼 — 클릭 전 hover, 키 입력 시 keydown/keyup 모두 발생. fireEvent.click 같은 저수준 API 보다 항상 우위.

4. async 검증 — findBy · waitFor

fetch 같은 비동기 결과는 즉시 안 나타남. findBy (있을 때까지 기다림) 또는 waitFor.

test('로그인 성공 시 환영 메시지', async () => { render(<App />); const user = userEvent.setup(); await user.type(screen.getByLabelText('이메일'), '[email protected]'); await user.type(screen.getByLabelText('비밀번호'), 'pwd'); await user.click(screen.getByRole('button', { name: '로그인' })); // findBy = 최대 1초 기다리며 폴링 const welcome = await screen.findByText(/환영합니다, admin/); expect(welcome).toBeInTheDocument(); });

3 family — getBy (즉시, 없으면 throw), queryBy (즉시, 없으면 null), findBy (대기, 있을 때까지). async 결과는 무조건 findBy.

5. fetch 모킹 — MSW (Mock Service Worker)

실제 API 안 부르고 가짜 응답으로 테스트. MSW 가 React 테스트의 사실상 표준.

npm install -D msw // src/test-setup.ts import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; const server = setupServer( http.get('/api/users/:id', () => HttpResponse.json({ id: 1, name: '박준성' })) ); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); // 테스트 안에서 응답 변경 test('서버 에러 표시', async () => { server.use( http.get('/api/users/:id', () => HttpResponse.error()) ); render(<UserProfile id={1} />); expect(await screen.findByText(/에러/)).toBeInTheDocument(); });

MSW 가 진짜 가치 — 네트워크 레이어에서 가로채기. fetch·axios·React Query 어느 도구로 호출해도 동일하게 가짜 응답. 개발 중에도 사용 가능 (서버 없이 프론트 개발).

테스트는 얼마나 써야 하나 — 100% 커버리지는 함정. 핵심 흐름 (로그인·결제·핵심 기능) 만 잘 테스트하는 게 80/20. 컴포넌트 단위보다 사용자 시나리오 단위 가 ROI 높다. e2e 테스트 (Playwright) 와 결합하면 더 완성.

21편으로 React 의 "신뢰 가능한 코드 작성" 까지 끝. 22편부터는 시각·구조 정리 — TypeScript 와 결합한 폼 검증 (Zod + React Hook Form) 으로 production 폼의 표준.

다음 글

React 교재 22편 — Zod + React Hook Form. 회사 stack 의 폼 검증 표준. 타입 안전 + 런타임 검증.

© 2026 주나이테크(주) @JUNAITECH