🎯 학습 목표
- Jest와 Vitest의 차이를 이해하고 Vite 프로젝트에 Vitest를 설정할 수 있다
- render, screen.getByRole, userEvent.click 기본 테스트 패턴을 작성할 수 있다
- 비동기 테스트에서 waitFor와 findBy*를 올바르게 사용한다
- MSW로 API 목킹을 설정하는 방법을 이해한다
- 좋은 테스트와 나쁜 테스트를 구분하는 기준을 안다
📖 핵심 개념 1 — Jest vs Vitest
Jest는 오랫동안 React 테스팅의 표준이었지만, Vite 기반 프로젝트에서는 설정이 복잡합니다. Vitest는 Vite와 동일한 설정을 공유하므로 Vite 프로젝트에 자연스럽게 통합됩니다.
// Vitest 설치 및 설정
// npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
// vite.config.js에 테스트 설정 추가
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // 브라우저 환경 시뮬레이션
globals: true, // describe, it, expect를 전역으로 사용
setupFiles: './src/test/setup.js', // 테스트 전 실행할 설정 파일
},
});
// src/test/setup.js
import '@testing-library/jest-dom'; // toBeInTheDocument 등 추가 매처
📖 핵심 개념 2 — 기본 테스트 패턴
React Testing Library의 핵심 철학: “구현 세부사항이 아니라 사용자 관점에서 테스트하라”
// components/Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import Counter from './Counter';
describe('Counter 컴포넌트', () => {
it('초기 카운트 0이 화면에 보인다', () => {
render(<Counter />);
// getByRole: 접근성 역할(role)로 요소를 찾음 — 권장 방식
expect(screen.getByRole('heading', { name: /카운트: 0/i })).toBeInTheDocument();
});
it('+1 버튼 클릭 시 카운트가 1 증가한다', async () => {
// userEvent.setup()으로 사용자 이벤트 시뮬레이션
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: '+1' });
await user.click(button);
expect(screen.getByRole('heading', { name: /카운트: 1/i })).toBeInTheDocument();
});
it('초기화 버튼 클릭 시 0으로 돌아간다', async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
await user.click(screen.getByRole('button', { name: '+1' }));
await user.click(screen.getByRole('button', { name: '초기화' }));
expect(screen.getByText('카운트: 0')).toBeInTheDocument();
});
});
screen 쿼리 선택 우선순위
// 권장 순서 (접근성 기반 → 의미적 → 마지막 수단)
screen.getByRole('button', { name: '제출' }) // 1순위: role + 접근성 이름
screen.getByLabelText('이메일') // 2순위: 폼 레이블
screen.getByPlaceholderText('이메일 입력') // 3순위: placeholder
screen.getByText('환영합니다') // 4순위: 텍스트 내용
screen.getByTestId('submit-btn') // 최후 수단: data-testid
// ❌ 피해야 할 방식 — 구현 세부사항에 의존
document.querySelector('.submit-button') // CSS 클래스 (구현 세부사항)
document.getElementById('btn-1') // ID (구현 세부사항)
📖 핵심 개념 3 — 비동기 테스트
// components/UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
// API를 모킹 (MSW 없이 간단히 모킹)
vi.mock('../api/userApi', () => ({
fetchUser: vi.fn(() => Promise.resolve({ id: 1, name: '김개발' })),
}));
describe('UserProfile', () => {
it('로딩 후 사용자 이름이 표시된다', async () => {
render(<UserProfile userId={1} />);
// 로딩 중 상태 확인
expect(screen.getByText('로딩 중...')).toBeInTheDocument();
// 방법 1: waitFor — 조건이 충족될 때까지 기다림
await waitFor(() => {
expect(screen.getByText('김개발')).toBeInTheDocument();
});
// 방법 2: findBy* — waitFor + getBy의 조합 (더 간결)
// const nameEl = await screen.findByText('김개발');
// expect(nameEl).toBeInTheDocument();
});
it('API 에러 시 에러 메시지가 표시된다', async () => {
const { fetchUser } = await import('../api/userApi');
fetchUser.mockRejectedValueOnce(new Error('서버 오류'));
render(<UserProfile userId={999} />);
const errorMsg = await screen.findByText(/에러:/i);
expect(errorMsg).toBeInTheDocument();
});
});
📖 핵심 개념 4 — MSW(Mock Service Worker)
MSW는 Service Worker를 활용해 네트워크 요청을 가로채는 API 목킹 도구입니다. 테스트 코드와 실제 코드 사이에 계층을 두지 않고, 실제 fetch 요청을 인터셉트하므로 더 현실적인 테스트가 가능합니다.
// npm install -D msw
// src/mocks/handlers.js — API 핸들러 정의
import { http, HttpResponse } from 'msw';
export const handlers = [
// GET 요청 목킹
http.get('https://api.example.com/users', () => {
return HttpResponse.json([
{ id: 1, name: '김개발' },
{ id: 2, name: '박프론트' },
]);
}),
// 동적 파라미터
http.get('https://api.example.com/users/:id', ({ params }) => {
const { id } = params;
return HttpResponse.json({ id: Number(id), name: `사용자 ${id}` });
}),
// POST 요청
http.post('https://api.example.com/users', async ({ request }) => {
const newUser = await request.json();
return HttpResponse.json({ id: Date.now(), ...newUser }, { status: 201 });
}),
// 에러 응답 시뮬레이션
http.get('https://api.example.com/error', () => {
return HttpResponse.json({ message: '서버 내부 오류' }, { status: 500 });
}),
];
// src/mocks/server.js — Node 환경(테스트)용 서버
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// src/test/setup.js에 추가
import { server } from '../mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // 테스트별 핸들러 초기화
afterAll(() => server.close());
💻 코드 예제 — 좋은 테스트 vs 나쁜 테스트
// ❌ 나쁜 테스트 — 구현 세부사항 테스트
it('useState가 올바르게 업데이트된다', () => {
const { result } = renderHook(() => useState(0));
act(() => result.current[1](1));
expect(result.current[0]).toBe(1); // 내부 구현 테스트 — 리팩토링 시 깨짐
});
it('handleClick 함수가 호출된다', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick} />);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1); // OK지만 사용자 행동 테스트가 더 좋음
});
// ✅ 좋은 테스트 — 사용자 관점의 행동 테스트
it('버튼 클릭 시 항목이 목록에 추가된다', async () => {
const user = userEvent.setup();
render(<TodoApp />);
// 사용자가 하는 행동을 그대로 시뮬레이션
await user.type(screen.getByPlaceholderText('할 일 입력'), 'React 테스트 공부');
await user.click(screen.getByRole('button', { name: '추가' }));
// 사용자가 볼 수 있는 결과를 확인
expect(screen.getByText('React 테스트 공부')).toBeInTheDocument();
});
it('항목 삭제 시 목록에서 사라진다', async () => {
const user = userEvent.setup();
render(<TodoApp initialItems={[{ id: 1, text: '삭제할 항목', done: false }]} />);
await user.click(screen.getByRole('button', { name: '삭제' }));
expect(screen.queryByText('삭제할 항목')).not.toBeInTheDocument();
});
⚠️ 흔한 실수 (よくあるミス)
- act() 경고 무시: “Warning: Not wrapped in act(…)”는 비동기 상태 업데이트가 테스트 외부에서 발생하고 있다는 신호입니다.
waitFor나findBy*로 처리하세요. - getBy vs queryBy vs findBy 혼동:
getBy는 없으면 즉시 에러,queryBy는 없으면 null 반환(존재하지 않음 검증에 사용),findBy는 비동기로 기다림. - 테스트마다 독립성 확보 실패: 전역 상태나 모듈 캐시가 테스트 간에 공유되면 순서에 따라 결과가 달라집니다.
afterEach에서 초기화하세요. - 스냅샷 테스트 남용: 스냅샷은 빠르게 구식이 되고, 의도를 드러내지 않습니다. 작은 컴포넌트의 구조 검증에만 제한적으로 사용하세요.
💡 실무 팁
- 커버리지보다 품질: 100% 커버리지를 목표로 하다 보면 의미 없는 테스트가 늘어납니다. 비즈니스 로직과 사용자 인터랙션 중심으로 테스트하세요.
- 테스트 피라미드: 단위 테스트(많음) → 통합 테스트(중간) → E2E 테스트(적음). E2E는 Playwright나 Cypress를 사용합니다.
- MSW를 개발에도 활용: MSW의 브라우저 모드를 사용하면 백엔드 없이도 프론트엔드 개발을 시작할 수 있습니다.