React Testing Library(RTL)는 "사용자가 소프트웨어를 사용하는 방식과 유사하게 테스트할수록 더 많은 신뢰를 얻는다"는 철학을 따릅니다. 컴포넌트의 내부 상태나 메서드가 아니라 실제로 DOM에 무엇이 렌더링되는지, 사용자가 어떻게 상호작용하는지를 기준으로 테스트를 작성합니다.
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
// src/test/setup.ts
import "@testing-library/jest-dom"; // toBeInTheDocument() 등 DOM 매처 등록
// vitest.config.ts
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
globals: true,
},
});
render는 React 컴포넌트를 가상 DOM(jsdom)에 렌더링하는 함수입니다. 테스트의 시작점으로, 렌더링 결과를 쿼리하기 위한 다양한 유틸리티를 반환합니다.
import { render } from "@testing-library/react";
import Button from "./Button";
test("버튼을 렌더링한다", () => {
const { getByText, getByRole, container } = render(
<Button>클릭하세요</Button>
);
// render가 반환하는 주요 유틸리티
// getByText → 텍스트로 요소 탐색
// getByRole → ARIA role로 요소 탐색
// container → 렌더링된 DOM 컨테이너 (div 엘리먼트)
});
Context나 라우터가 필요한 컴포넌트는 wrapper 옵션으로 감쌀 수 있습니다.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
const queryClient = new QueryClient();
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>
);
}
test("Context가 필요한 컴포넌트 테스트", () => {
render(<UserProfile />, { wrapper: Wrapper });
// ...
});
반복되는 Wrapper는 renderWithProviders 같은 커스텀 함수로 추출해 재사용하는 것이 일반적입니다.
screen은 현재 렌더링된 DOM을 쿼리하는 전역 객체입니다. render의 반환값을 구조 분해하지 않고 screen을 통해 쿼리하는 방식이 권장됩니다. 어느 render 호출에서 렌더링됐는지 신경 쓸 필요 없이 전체 문서를 기준으로 탐색하기 때문입니다.
import { render, screen } from "@testing-library/react";
import LoginForm from "./LoginForm";
test("로그인 폼이 렌더링된다", () => {
render(<LoginForm />);
// screen을 통해 요소를 탐색
expect(screen.getByRole("heading", { name: "로그인" })).toBeInTheDocument();
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
expect(screen.getByLabelText("비밀번호")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "로그인" })).toBeInTheDocument();
});
screen.debug로 현재 DOM 상태를 콘솔에 출력할 수 있어 디버깅에 유용합니다.
screen.debug(); // 전체 DOM 출력
screen.debug(screen.getByRole("form")); // 특정 요소만 출력
userEvent는 실제 사용자 인터랙션(클릭, 타이핑, 포커스 등)을 시뮬레이션하는 라이브러리입니다. @testing-library/user-event 패키지로 제공됩니다.
userEvent는 단순히 이벤트 하나를 발생시키는 게 아니라 실제 브라우저 동작에 가까운 이벤트 시퀀스를 재현합니다. 예를 들어 클릭은 pointerover → pointerenter → mouseover → mouseenter → pointermove → mousemove → pointerdown → mousedown → pointerup → mouseup → click 순서로 발생합니다.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Counter from "./Counter";
test("버튼을 클릭하면 카운트가 증가한다", async () => {
// userEvent.setup()으로 인스턴스 생성 (v14+)
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText("0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "증가" }));
expect(screen.getByText("1")).toBeInTheDocument();
});
test("입력 필드에 타이핑한다", async () => {
const user = userEvent.setup();
render(<SearchInput />);
const input = screen.getByRole("textbox");
await user.type(input, "React");
expect(input).toHaveValue("React");
});
userEvent.setup으로 생성한 인스턴스를 사용하면 클립보드, 포인터 상태 등이 테스트 간 공유되지 않아 더 안전합니다.
fireEvent는 DOM 이벤트를 직접 발생시키는 저수준 유틸리티입니다. userEvent가 이벤트 시퀀스를 재현하는 반면, fireEvent는 단일 이벤트를 즉시 발생시킵니다.
import { render, screen, fireEvent } from "@testing-library/react";
import Select from "./Select";
test("select 변경 이벤트를 발생시킨다", () => {
render(<Select />);
const select = screen.getByRole("combobox");
fireEvent.change(select, { target: { value: "option2" } });
expect(select).toHaveValue("option2");
});
// 그 외 fireEvent 메서드
fireEvent.click(element);
fireEvent.focus(element);
fireEvent.blur(element);
fireEvent.keyDown(element, { key: "Enter", code: "Enter" });
fireEvent.submit(formElement);
userEvent vs fireEvent
userEvent → 실제 브라우저와 동일한 이벤트 시퀀스
비동기(async/await 필요), 더 현실적
fireEvent → 단일 이벤트만 발생
동기, 빠르지만 실제 동작과 다를 수 있음
일반적으로는 userEvent를 사용하는 것이 권장됩니다. fireEvent는 userEvent로 재현하기 어려운 특수한 DOM 이벤트를 직접 트리거해야 할 때 사용합니다.
Query는 렌더링된 DOM에서 요소를 찾는 방법입니다. 찾는 방식(접두사)과 찾는 기준(접미사)의 조합으로 구성됩니다.
접두사: 동작 방식
| 접두사 | 없으면 | 비동기 | 반환 |
|---|---|---|---|
getBy | 즉시 에러 | X | 단일 요소 |
queryBy | null 반환 | X | 단일 요소 또는 null |
findBy | 타임아웃 에러 | O (Promise) | 단일 요소 |
getAllBy | 즉시 에러 | X | 배열 |
queryAllBy | [] 반환 | X | 배열 |
findAllBy | 타임아웃 에러 | O (Promise) | 배열 |
요소가 있을 때 → getBy (즉시 검증 가능)
요소가 없음을 확인 → queryBy (null 반환이므로 .not.toBeInTheDocument() 사용)
비동기로 나타남 → findBy (Promise 반환, async/await 필요)
접미사: 찾는 기준
RTL은 접근성을 고려한 우선순위를 권장합니다.
우선순위 (높음 → 낮음)
1. ByRole → ARIA role 기반 (가장 권장)
2. ByLabelText → label의 텍스트 기반
3. ByPlaceholderText → placeholder 속성
4. ByText → 가시적 텍스트 내용
5. ByDisplayValue → 폼 요소의 현재 값
6. ByAltText → img의 alt 속성
7. ByTitle → title 속성
8. ByTestId → data-testid 속성 (최후 수단)
// ByRole 예시 — 가장 권장되는 방식
screen.getByRole("button", { name: "제출" });
screen.getByRole("heading", { level: 2 });
screen.getByRole("textbox", { name: "이메일" });
screen.getByRole("checkbox", { name: "약관 동의" });
// ByLabelText — 접근성 label과 연결된 input 탐색
screen.getByLabelText("비밀번호");
// ByText — 텍스트 내용으로 탐색
screen.getByText("로그인");
screen.getByText(/로그인/i); // 정규식 사용 가능
// ByTestId — 다른 방법으로 찾기 어려울 때
screen.getByTestId("loading-spinner");
외부 모듈, API 호출, 타이머 등은 Mock으로 대체해 테스트가 외부 환경에 의존하지 않도록 합니다.
모듈 Mock (vi.mock):
import { vi } from "vitest";
import { render, screen } from "@testing-library/react";
import UserCard from "./UserCard";
import * as api from "./api";
vi.mock("./api");
test("유저 이름을 표시한다", async () => {
vi.spyOn(api, "fetchUser").mockResolvedValue({
id: 1,
name: "Alice",
email: "alice@example.com",
});
render(<UserCard userId={1} />);
expect(await screen.findByText("Alice")).toBeInTheDocument();
});
커스텀 훅 Mock:
vi.mock("./useAuth", () => ({
useAuth: () => ({
user: { name: "Alice" },
isLoggedIn: true,
logout: vi.fn(),
}),
}));
타이머 Mock:
test("3초 후 에러 메시지가 사라진다", async () => {
vi.useFakeTimers();
render(<ErrorMessage message="에러!" />);
expect(screen.getByText("에러!")).toBeInTheDocument();
vi.advanceTimersByTime(3000);
await screen.findByText(/에러/i); // 사라졌는지 확인
vi.useRealTimers();
});
비동기 동작(API 호출, 로딩 상태, 애니메이션 등)을 테스트할 때는 DOM이 업데이트될 때까지 기다리는 패턴이 필요합니다.
findBy — 가장 일반적인 비동기 쿼리:
test("데이터 로딩 후 목록을 표시한다", async () => {
vi.spyOn(api, "fetchItems").mockResolvedValue([
{ id: 1, name: "항목 1" },
{ id: 2, name: "항목 2" },
]);
render(<ItemList />);
// 로딩 중에는 스피너가 보임
expect(screen.getByText("로딩 중...")).toBeInTheDocument();
// API 응답 후 항목이 나타날 때까지 대기 (기본 1000ms 타임아웃)
expect(await screen.findByText("항목 1")).toBeInTheDocument();
expect(screen.getByText("항목 2")).toBeInTheDocument();
expect(screen.queryByText("로딩 중...")).not.toBeInTheDocument();
});
waitFor — 여러 단언을 비동기로 기다릴 때:
import { waitFor } from "@testing-library/react";
test("폼 제출 후 성공 메시지를 표시한다", async () => {
const user = userEvent.setup();
render(<ContactForm />);
await user.type(screen.getByLabelText("이름"), "Alice");
await user.type(screen.getByLabelText("메시지"), "안녕하세요");
await user.click(screen.getByRole("button", { name: "제출" }));
// 여러 조건이 동시에 만족될 때까지 폴링
await waitFor(() => {
expect(screen.getByText("제출이 완료됐습니다.")).toBeInTheDocument();
expect(screen.queryByRole("form")).not.toBeInTheDocument();
});
});
waitForElementToBeRemoved — 요소가 사라질 때까지 대기:
test("로딩 스피너가 사라진다", async () => {
render(<DataTable />);
await waitForElementToBeRemoved(() => screen.queryByText("로딩 중..."));
expect(screen.getByRole("table")).toBeInTheDocument();
});
비동기 선택 기준
단일 요소 등장 대기 → findBy
여러 단언 한번에 → waitFor
요소 사라짐 대기 → waitForElementToBeRemoved