React Testing Library(RTL)는 사용자의 관점에서 컴포넌트를 테스트하도록 돕는 도구입니다. DOM 구조나 내부 상태 같은 구현 세부에 의존하지 않고, 접근성 가능한 쿼리로 화면을 찾고 사용자 이벤트를 시뮬레이션합니다. 실무에서 바로 쓸 수 있는 설정과 패턴을 예제로 정리합니다.
1. 설치와 기본 설정
Jest와 RTL, jest-dom, user-event를 설치합니다. 테스트 환경은 jsdom이어야 합니다.
// 설치
// npm
npm i -D jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom
// yarn
yarn add -D jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
};// src/setupTests.js
import '@testing-library/jest-dom';package.json에 스크립트를 추가합니다.
{
"scripts": {
"test": "jest --watch"
}
}2. 첫 번째 테스트: 버튼 토글
접근성 가능한 역할(role)과 이름(name)으로 요소를 찾고, user-event로 상호작용합니다.
// ToggleButton.jsx
import { useState } from 'react';
export default function ToggleButton() {
const [on, setOn] = useState(false);
return (
<button aria-pressed={on} onClick={() => setOn((v) => !v)}>
{on ? '켜짐' : '꺼짐'}
</button>
);
}// ToggleButton.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ToggleButton from './ToggleButton';
test('버튼 토글 시 텍스트와 aria-pressed가 바뀝니다', async () => {
render(<ToggleButton />);
const user = userEvent.setup();
const button = screen.getByRole('button', { name: '꺼짐' });
expect(button).toHaveAttribute('aria-pressed', 'false');
await user.click(button);
expect(button).toHaveTextContent('켜짐');
expect(button).toHaveAttribute('aria-pressed', 'true');
});3. 접근성 우선 쿼리 전략
RTL은 접근성 우선 쿼리를 권장합니다. 우선순위는 대략 다음과 같습니다.
1) getByRole 2) getByLabelText 3) getByPlaceholderText 4) getByText 5) getByDisplayValue 6) getByAltText 7) getByTitle 8) getByTestId(최후)
// 폼 라벨-컨트롤 연결
// Form.jsx
export function Form() {
return (
<form>
<label htmlFor="email">이메일</label>
<input id="email" type="email" placeholder="name@example.com" />
<button type="submit">전송</button>
</form>
);
}// Form.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Form } from './Form';
it('라벨로 인풋을 찾고 입력할 수 있습니다', async () => {
render(<Form />);
const user = userEvent.setup();
const email = screen.getByLabelText(/이메일/);
await user.type(email, 'a@b.com');
expect(email).toHaveValue('a@b.com');
// 역할과 접근 가능한 이름으로 버튼 찾기
const submit = screen.getByRole('button', { name: '전송' });
expect(submit).toBeEnabled();
});4. 비동기 UI: findBy와 waitFor
비동기 렌더링은 findBy 계열 쿼리(내장 타임아웃 동안 대기)나 waitFor를 사용합니다. 네트워크는 모듈 경계에서 모킹합니다.
// Greeting.jsx
import { useEffect, useState } from 'react';
export function Greeting() {
const [name, setName] = useState(null);
useEffect(() => {
let ignore = false;
fetch('/api/me')
.then((r) => r.json())
.then((data) => { if (!ignore) setName(data.name); });
return () => { ignore = true; };
}, []);
return <div>{name ? `안녕하세요, ${name}` : '로딩...' }</div>;
}// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';
it('API 응답 후 인사말을 표시합니다', async () => {
const mock = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ name: '민수' }),
});
render(<Greeting />);
expect(screen.getByText('로딩...')).toBeInTheDocument();
// DOM에 나타날 때까지 대기
expect(await screen.findByText('안녕하세요, 민수')).toBeInTheDocument();
mock.mockRestore();
});컴포넌트 외부 네트워크 경계에서 모킹하려면 MSW(Mock Service Worker) 사용을 권장합니다. 테스트가 구현 세부에 덜 결합되고, 통합 흐름을 자연스럽게 검증할 수 있습니다.
5. user-event로 실제에 가까운 상호작용
user-event는 타이핑 지연, 포커스 이동 등 브라우저 동작을 더 현실적으로 시뮬레이션합니다. 비동기 동작을 고려해 setup을 사용하고 await를 붙입니다.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('Enter 키 제출과 포커스 이동을 처리합니다', async () => {
render(<form>
<input aria-label="이름" />
<button>저장</button>
</form>);
const user = userEvent.setup();
const input = screen.getByLabelText('이름');
await user.type(input, '홍길동{enter}');
// 제출 핸들러가 있다면 제출되었는지 검증, 여기서는 버튼이 포커스를 받았다고 가정
expect(screen.getByRole('button', { name: '저장' })).toHaveFocus();
});6. 컨텍스트/라우터 등 Provider가 필요한 컴포넌트
여러 Provider가 필요한 컴포넌트를 매번 감싸는 대신, 커스텀 렌더 함수를 만들어 재사용합니다.
// test-utils.jsx
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
function Providers({ children }) {
return <MemoryRouter>{children}</MemoryRouter>;
}
export function renderWithProviders(ui, options) {
return render(ui, { wrapper: Providers, ...options });
}
export * from '@testing-library/react';// Page.test.jsx
import { screen } from '@testing-library/react';
import { renderWithProviders } from './test-utils';
import Page from './Page';
it('페이지 제목을 렌더링합니다', () => {
renderWithProviders(<Page />);
expect(screen.getByRole('heading', { level: 1, name: /대시보드/ })).toBeInTheDocument();
});7. 모듈/타이머 모킹 팁
모듈 의존성을 고립시키려면 jest.mock을 사용합니다. 타이머 기반 로직은 가짜 타이머로 제어할 수 있습니다.
// 모듈 모킹 예시
jest.mock('./analytics', () => ({ track: jest.fn() }));
// 타이머 제어 예시
jest.useFakeTimers();
it('디바운스 후 호출됩니다', () => {
const fn = jest.fn();
render(<button onClick={() => setTimeout(fn, 300)}>Run</button>);
screen.getByRole('button', { name: 'Run' }).click();
jest.advanceTimersByTime(300);
expect(fn).toHaveBeenCalled();
});
afterEach(() => {
jest.useRealTimers();
});8. 피해야 할 테스트 냄새
- 구현 세부 의존: className, 내부 state 값 직접 확인, container.querySelector로 깊게 파고들기
- 스냅샷 남용: 큰 트리 스냅샷은 노이즈가 큽니다. 대신 화면 텍스트, 역할, 속성으로 구체적 단언을 합니다.
- testId 남용: 접근 가능한 역할/이름으로 찾을 수 있다면 우선 사용합니다. testId는 진짜 대안이 없을 때만 사용합니다.
9. 디버깅 도구
- screen.debug(): 현재 DOM 출력
- logRoles(container): 어떤 role이 할당됐는지 확인
import { render, screen } from '@testing-library/react';
import { logRoles } from '@testing-library/dom';
const { container } = render(<YourComponent />);
logRoles(container);
screen.debug();10. 체크리스트
- 사용자 관점으로 쓰여졌는가? (getByRole/LabelText 우선)
- 비동기는 findBy/waitFor를 사용했는가?
- Provider는 커스텀 렌더로 감쌌는가?
- 네트워크/시간/모듈 경계를 적절히 모킹했는가?
- 과도한 스냅샷과 testId를 피했는가?
위 원칙을 지키면, 리팩터링에 강하고 유지보수 비용이 낮은 테스트를 작성할 수 있습니다. 테스트는 구현이 아닌 동작을 검증해야 합니다.
'React' 카테고리의 다른 글
| React에서 WebSocket 연결 구현하기 (0) | 2026.04.18 |
|---|---|
| React와 Redux Toolkit으로 상태 관리 구조화하기 (1) | 2026.04.17 |
| React에서 Error Boundary로 오류 처리하기 (1) | 2026.04.16 |
| React 앱에서 Lazy Loading 이미지 처리하기 (1) | 2026.04.16 |
| React에서 Formik과 Yup으로 폼 검증 구현하기 (0) | 2026.04.15 |