반응형
React Testing Library — 사용자 관점에서 테스트하기
프론트엔드 개발 · 테스팅 시리즈 #3
"구현 세부사항이 아닌 동작을 테스트하라." RTL의 핵심 철학부터 쿼리 우선순위, 사용자 이벤트, 비동기 처리, 커스텀 훅 테스트까지 — 실전에서 바로 쓸 수 있는 완전한 가이드입니다.
목차
- React Testing Library란 무엇인가
- 설치 및 기본 설정
- 쿼리 우선순위 — 올바른 요소 선택법
- user-event — 실제 사용자 상호작용
- 비동기 테스트 — waitFor, findBy
- 컴포넌트 테스트 실전 예제
- 커스텀 훅 테스트
- Context와 Provider 테스트
- MSW로 API 모킹
- 결론 — 좋은 RTL 테스트의 원칙
1. React Testing Library란 무엇인가
React Testing Library(RTL) 는 Kent C. Dodds가 만든 React 컴포넌트 테스트 유틸리티입니다. 핵심 철학은 단 하나입니다.
"사용자가 소프트웨어를 사용하는 방식과 유사한 방식으로 테스트할수록, 테스트에 대한 신뢰가 높아진다."
RTL이 해결하는 문제
Enzyme(이전 세대 테스팅 라이브러리)은 컴포넌트의 내부 구현을 테스트했습니다.
// Enzyme 방식 — 구현 세부사항 테스트 (나쁜 패턴)
const wrapper = shallow(<Button />);
expect(wrapper.state('isClicked')).toBe(false);
wrapper.find('.button').simulate('click');
expect(wrapper.state('isClicked')).toBe(true);
// 문제: 상태 이름을 'clicked'로 바꾸면 테스트가 깨짐
// 하지만 사용자 관점에서는 아무것도 바뀌지 않았음
RTL은 실제 DOM을 렌더링하고, 사용자가 실제로 보고 상호작용하는 방식으로 테스트합니다.
// RTL 방식 — 동작 테스트 (좋은 패턴)
render(<Button />);
const button = screen.getByRole('button', { name: '제출' });
await userEvent.click(button);
expect(screen.getByText('제출 완료!')).toBeInTheDocument();
// 내부 상태가 어떻게 바뀌든 관심 없음
// 사용자가 경험하는 결과를 테스트
2. 설치 및 기본 설정
# RTL + jest-dom 설치
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
# Vitest 사용 시 (jsdom 필요)
npm install --save-dev jsdom
setupTests.ts
// src/setupTests.ts
import '@testing-library/jest-dom';
// 이것으로 toBeInTheDocument, toHaveTextContent 등의 매처 사용 가능
// vitest.config.ts
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/setupTests.ts'],
globals: true,
},
});
기본 테스트 구조
// Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button 컴포넌트', () => {
it('텍스트를 렌더링한다', () => {
render(<Button>클릭하세요</Button>);
expect(screen.getByText('클릭하세요')).toBeInTheDocument();
});
it('클릭 시 onClick을 호출한다', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>클릭</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
3. 쿼리 우선순위 — 올바른 요소 선택법
RTL은 다양한 쿼리를 제공합니다. 접근성을 가장 잘 반영하는 쿼리를 우선 사용해야 합니다.
우선순위 1 — 모든 사람이 접근 가능한 쿼리
// getByRole — 가장 권장 (ARIA 역할 기반)
screen.getByRole('button')
screen.getByRole('button', { name: '제출' }) // accessible name 포함
screen.getByRole('textbox', { name: '이메일' })
screen.getByRole('checkbox', { name: '동의' })
screen.getByRole('heading', { level: 1 })
screen.getByRole('img', { name: '프로필 사진' })
screen.getByRole('link', { name: '홈으로' })
screen.getByRole('combobox') // select
screen.getByRole('listitem')
screen.getByRole('dialog')
// getByLabelText — 폼 요소 (label과 연결)
screen.getByLabelText('이메일 주소')
screen.getByLabelText(/이메일/)
// getByPlaceholderText — placeholder 텍스트
screen.getByPlaceholderText('hong@example.com')
// getByText — 텍스트 내용
screen.getByText('안녕하세요')
screen.getByText(/안녕/) // 정규식
// getByDisplayValue — input/select의 현재 값
screen.getByDisplayValue('홍길동')
우선순위 2 — 시맨틱 쿼리
// getByAltText — img의 alt 속성
screen.getByAltText('로고 이미지')
// getByTitle — title 속성
screen.getByTitle('닫기')
우선순위 3 (최후 수단) — 테스트 ID
// getByTestId — data-testid 속성
screen.getByTestId('submit-button')
// HTML: <button data-testid="submit-button">제출</button>
// 접근성 정보가 없을 때만 사용
getBy vs queryBy vs findBy
// getBy — 요소가 없으면 즉시 에러 (동기)
screen.getByRole('button') // 없으면 에러
// queryBy — 요소가 없으면 null 반환 (동기, 존재 여부 확인에 사용)
screen.queryByRole('button') // 없으면 null
expect(screen.queryByText('에러')).not.toBeInTheDocument()
// findBy — 비동기, 요소가 나타날 때까지 대기 (Promise)
await screen.findByRole('button') // 나타날 때까지 기다림
await screen.findByText('로딩 완료')
// AllBy 변형 — 여러 요소 반환
screen.getAllByRole('listitem')
screen.queryAllByRole('button')
await screen.findAllByRole('img')
4. user-event — 실제 사용자 상호작용
@testing-library/user-event v14는 실제 브라우저의 사용자 동작을 시뮬레이션합니다.
import userEvent from '@testing-library/user-event';
// setup() 패턴 권장 (v14+)
const user = userEvent.setup();
// 클릭
await user.click(element);
await user.dblClick(element); // 더블 클릭
await user.tripleClick(element); // 세 번 클릭 (텍스트 전체 선택)
// 키보드
await user.type(input, '안녕하세요'); // 한 글자씩 타이핑
await user.clear(input); // 입력값 지우기
await user.keyboard('{Enter}'); // 특수 키
await user.keyboard('{Tab}'); // Tab 이동
await user.keyboard('{Escape}'); // Esc
await user.keyboard('{Control>}a{/Control}'); // Ctrl+A
// 폼 요소
await user.selectOptions(selectEl, '옵션2');
await user.deselectOptions(multiSelectEl, '옵션1');
await user.upload(fileInput, file); // 파일 업로드
// 호버
await user.hover(element);
await user.unhover(element);
// 탭 순서
await user.tab(); // 다음 요소로 포커스 이동
await user.tab({ shift: true }); // 이전 요소로
fireEvent vs userEvent
// fireEvent — 단순 DOM 이벤트 발생 (낮은 수준)
fireEvent.click(button);
// userEvent — 실제 브라우저 동작 시뮬레이션 (권장)
// 예: type은 keydown → keypress → input → keyup 이벤트를 순서대로 발생
await user.type(input, 'hello');
// fireEvent.change로는 onChange만 발생하지만
// userEvent.type은 실제 타이핑의 모든 이벤트를 순서대로 발생시킴
5. 비동기 테스트 — waitFor, findBy
// waitFor — 조건이 충족될 때까지 반복 확인
import { waitFor } from '@testing-library/react';
it('데이터 로드 후 목록을 표시한다', async () => {
render(<UserList />);
// 로딩 스피너가 사라질 때까지 대기
await waitFor(() => {
expect(screen.queryByText('로딩 중...')).not.toBeInTheDocument();
});
// 또는 findBy로 요소가 나타날 때까지 대기
const listItems = await screen.findAllByRole('listitem');
expect(listItems).toHaveLength(3);
});
// waitForElementToBeRemoved — 요소가 사라질 때까지 대기
it('제출 후 모달이 닫힌다', async () => {
render(<Modal />);
await user.click(screen.getByRole('button', { name: '제출' }));
await waitForElementToBeRemoved(() =>
screen.queryByRole('dialog')
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
6. 컴포넌트 테스트 실전 예제
폼 컴포넌트 테스트
// LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
const user = userEvent.setup();
it('빈 폼 제출 시 유효성 에러를 표시한다', async () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(screen.getByText('이메일을 입력하세요')).toBeInTheDocument();
expect(screen.getByText('비밀번호를 입력하세요')).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it('잘못된 이메일 형식 입력 시 에러를 표시한다', async () => {
render(<LoginForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText('이메일'), 'invalid-email');
await user.tab(); // 포커스 이동으로 blur 트리거
expect(screen.getByText('올바른 이메일 형식이 아닙니다')).toBeInTheDocument();
});
it('올바른 정보 입력 시 onSubmit을 호출한다', async () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('이메일'), 'hong@example.com');
await user.type(screen.getByLabelText('비밀번호'), 'password123');
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'hong@example.com',
password: 'password123',
});
});
});
비동기 데이터 패칭 컴포넌트 테스트
// UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';
// API 모킹 (vi.mock 또는 MSW)
vi.mock('./api', () => ({
fetchUser: vi.fn(),
}));
import { fetchUser } from './api';
describe('UserProfile', () => {
it('로딩 상태를 표시한다', () => {
fetchUser.mockReturnValue(new Promise(() => {})); // 영원히 pending
render(<UserProfile userId={1} />);
expect(screen.getByText('로딩 중...')).toBeInTheDocument();
});
it('사용자 정보를 표시한다', async () => {
fetchUser.mockResolvedValue({
id: 1, name: '홍길동', email: 'hong@example.com', role: 'admin',
});
render(<UserProfile userId={1} />);
expect(await screen.findByText('홍길동')).toBeInTheDocument();
expect(screen.getByText('hong@example.com')).toBeInTheDocument();
expect(screen.getByText('관리자')).toBeInTheDocument();
});
it('에러 상태를 표시한다', async () => {
fetchUser.mockRejectedValue(new Error('네트워크 오류'));
render(<UserProfile userId={1} />);
expect(await screen.findByText('데이터를 불러올 수 없습니다')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '다시 시도' })).toBeInTheDocument();
});
});
7. 커스텀 훅 테스트
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('초기값으로 시작한다', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increment가 count를 1 증가시킨다', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('props 변경을 처리한다', () => {
const { result, rerender } = renderHook(
({ initialCount }) => useCounter(initialCount),
{ initialProps: { initialCount: 0 } }
);
expect(result.current.count).toBe(0);
rerender({ initialCount: 10 });
expect(result.current.count).toBe(10);
});
});
8. Context와 Provider 테스트
// ThemeProvider.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, useTheme } from './ThemeContext';
// 테스트용 래퍼 컴포넌트
function ThemeDisplay() {
const { theme, toggleTheme } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<button onClick={toggleTheme}>테마 전환</button>
</div>
);
}
describe('ThemeContext', () => {
const renderWithProvider = (ui: React.ReactElement) =>
render(<ThemeProvider>{ui}</ThemeProvider>);
it('기본 테마는 light다', () => {
renderWithProvider(<ThemeDisplay />);
expect(screen.getByTestId('theme')).toHaveTextContent('light');
});
it('테마 전환 버튼이 동작한다', async () => {
renderWithProvider(<ThemeDisplay />);
await userEvent.click(screen.getByRole('button', { name: '테마 전환' }));
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
});
});
// 커스텀 render 함수 — Provider 자동 주입
function customRender(ui: React.ReactElement, options = {}) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
),
...options,
});
}
9. MSW로 API 모킹
Mock Service Worker(MSW) 는 실제 HTTP 요청을 인터셉트해 모킹합니다. vi.mock보다 현실적인 테스트가 가능합니다.
npm install --save-dev msw
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
return HttpResponse.json({
id: Number(id),
name: '홍길동',
email: 'hong@example.com',
});
}),
http.post('/api/login', async ({ request }) => {
const body = await request.json();
if (body.email === 'hong@example.com' && body.password === 'correct') {
return HttpResponse.json({ token: 'mock-jwt-token' });
}
return HttpResponse.json(
{ message: '이메일 또는 비밀번호가 틀렸습니다' },
{ status: 401 }
);
}),
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// src/setupTests.ts
import { server } from './mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// UserProfile.test.tsx — MSW 사용
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
it('API 에러 시 에러 메시지를 표시한다', async () => {
// 이 테스트에서만 에러 핸들러로 오버라이드
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json({ message: 'Server Error' }, { status: 500 });
})
);
render(<UserProfile userId={1} />);
expect(await screen.findByText('데이터를 불러올 수 없습니다')).toBeInTheDocument();
});
10. 결론 — 좋은 RTL 테스트의 원칙
해야 할 것
// ✅ 접근성 쿼리 우선 사용
screen.getByRole('button', { name: '제출' })
screen.getByLabelText('이메일')
// ✅ 사용자 행동으로 테스트
await user.type(input, '홍길동');
await user.click(button);
// ✅ 결과(텍스트, 역할, 상태)로 단언
expect(screen.getByText('성공!')).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('에러 메시지');
하지 말아야 할 것
// ❌ 구현 세부사항 테스트
const { state } = component; // 내부 상태 직접 접근
// ❌ CSS 클래스로 요소 선택
document.querySelector('.btn-primary');
container.firstChild;
// ❌ getByTestId 남발 (최후 수단)
screen.getByTestId('submit-btn'); // 접근성 쿼리로 대체 가능한지 먼저 확인
// ❌ act() 경고 무시
// act() 경고는 항상 수정해야 할 신호
jest-dom 매처 활용
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toBeDisabled();
expect(element).toHaveTextContent('텍스트');
expect(element).toHaveValue('입력값');
expect(element).toHaveAttribute('aria-expanded', 'true');
expect(element).toHaveFocus();
expect(element).toBeChecked();
expect(element).toHaveClass('active');
expect(element).toHaveStyle({ color: 'red' });
한 줄 요약: RTL의 핵심은 "사용자가 볼 수 있는 것"과 "사용자가 할 수 있는 것"만 테스트하는 것입니다. 내부 구현이 바뀌어도 사용자 경험이 유지된다면 테스트가 깨져서는 안 됩니다.
이전 글: Vitest — Vite 네이티브 테스트 프레임워크
다음 글: Vue Test Utils — Vue 컴포넌트 테스트의 공식 도구
반응형
댓글