React Testing은(는) 무엇을 하나요?
동작 중심의 컴포넌트 테스트, 커스텀 훅 테스트, 접근성 어설션, 네트워크 레벨 모킹을 위한 포괄적인 React 테스트 패턴입니다.
활성화 시점
- React 컴포넌트, 커스텀 훅, 페이지에 대한 테스트를 작성할 때
- 레거시 미테스트 컴포넌트에 테스트 커버리지를 추가할 때
- Enzyme 또는 클래스 컴포넌트 패턴에서 React Testing Library로 마이그레이션할 때
- 새 React 프로젝트에 Vitest 또는 Jest를 설정할 때
- 테스트에서 HTTP 요청을 모킹할 때
- 접근성 위반 사항을 어설션할 때
- 어떤 테스트가 RTL에 속하고 어떤 것이 Playwright Component Testing 또는 전체 E2E에 속하는지 결정할 때
핵심 원칙
사용자가 보고 상호 작용하는 것을 테스트하고, 구현 세부 사항은 테스트하지 마십시오.
테스트는 다음을 수행해야 합니다:
- 프로덕션에서 사용하는 것과 동일한 프로바이더와 함께 컴포넌트를 렌더링합니다.
- 접근 가능한 쿼리(
getByRole,getByLabelText)와userEvent를 사용하여 상호 작용합니다. - 표시되는 출력과 관찰 가능한 부수 효과(콜백 호출, 요청 전송)를 어설션합니다.
테스트는 다음을 수행해서는 안 됩니다:
- 컴포넌트 상태, 자식에게 전달된 props, 또는 어떤 훅이 호출되었는지 검사합니다.
- React 자체 또는 프레임워크 훅을 모킹합니다.
- 사용자에게 영향을 미치는 것 이상으로 렌더링 횟수나 DOM 구조를 어설션합니다.
라이브러리 선택
| 러너 | 시기 | 참고 |
|---|---|---|
| Vitest | Vite, Remix, 최신 설정 | 더 빠름, 네이티브 ESM, Jest 호환 API |
| Jest | Next.js, CRA, 기존 저장소 | 많은 React 프로젝트의 기본값 |
| Playwright Component Testing | 실제 브라우저 엔진 필요 시 | JSDOM에 필요한 기능이 없을 때 사용 |
| Cypress Component Testing | 실제 브라우저, Cypress 이미 사용 중인 경우 | Playwright CT의 대안 |
하나를 선택하세요. 명확한 레인 분리가 없는 한 동일한 저장소에서 RTL + Vitest와 Playwright CT를 함께 실행하지 마십시오.
쿼리 우선순위
React Testing Library는 세 가지 계층의 쿼리를 제공합니다. 위에서부터 아래로 사용하십시오:
- 모두에게 접근 가능:
getByRole,getByLabelText,getByPlaceholderText,getByText,getByDisplayValue - 의미론적:
getByAltText,getByTitle - 테스트 ID (탈출구):
getByTestId
// 최고
screen.getByRole("button", { name: /save/i });
// 입력에 적합
screen.getByLabelText("Email");
// 최후의 수단
screen.getByTestId("save-btn");
변형:
getBy*— 일치하는 항목이 없으면 예외를 던집니다.queryBy*—null을 반환합니다 ("부재 어설션"에 사용).findBy*— 비동기, Promise를 반환합니다 (비동기 작업 후 나타나는 요소에 사용).
userEvent를 사용한 사용자 상호 작용
import userEvent from "@testing-library/user-event";
test("submits the form", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "[email protected]");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: "[email protected]" });
});
- 항상
await을 사용하여 userEvent 호출을 기다리십시오. - 테스트당 한 번
userEvent.setup()을 호출하고 반환된user를 재사용하십시오. userEvent는 실제 브라우저 시퀀스를 시뮬레이션합니다.fireEvent는 단일 합성 이벤트를 전달합니다.userEvent를 선호하십시오.
비동기 패턴
// 비동기 작업 후 나타나는 요소
await expect(screen.findByText("Loaded")).resolves.toBeInTheDocument();
// 부수 효과 어설션
await waitFor(() => expect(saveSpy).toHaveBeenCalled());
// 사라져야 하는 요소
await waitForElementToBeRemoved(() => screen.queryByText("Loading"));
절대로 setTimeout + 어설션을 사용하지 마십시오. 불안정합니다. 위의 매처를 사용하십시오.
MSW를 사용한 네트워크 모킹
Mock Service Worker는 네트워크 레이어에서 모킹합니다. 컴포넌트, 훅, fetch 라이브러리는 모두 프로덕션에서와 정확히 동일하게 동작합니다.
설정
// test/setup.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) =>
HttpResponse.json({ id: params.id, name: "Alice" }),
),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "new-id", ...body }, { status: 201 });
}),
];
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
onUnhandledRequest: "error"를 구성하여 모킹되지 않은 요청이 테스트를 실패하게 만드십시오. 조용히 통과하는 것은 실패보다 더 나쁩니다.
테스트별 재정의
test("renders error on 500", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<UserPage id="1" />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});
프로바이더 래핑
test-utils.tsx에서 프로바이더를 한 번 래핑하십시오:
// test-utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={lightTheme}>
<MemoryRouter>{ui}</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>,
options,
);
}
export * from "@testing-library/react";
그런 다음 모든 테스트 파일에서 import { renderWithProviders, screen } from "test-utils"를 사용하십시오.
커스텀 훅 테스팅
import { renderHook, act } from "@testing-library/react";
test("useCounter increments and decrements", () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
});
test("useCounter accepts initial value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test("useUser fetches user data", async () => {
// 래퍼 외부에서 테스트당 한 번 QueryClient를 인스턴스화하여 재렌더링 시에도 유지되도록 합니다.
// 래퍼 클로저 내부에서 생성하면 모든 렌더링 시 캐시 상태가 재설정되어 불안정한 테스트가 발생합니다.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useUser("1"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ id: "1", name: "Alice" });
});
- 상태 변경 호출을
act로 감싸십시오. - 훅의 퍼블릭 API를 통해서만 테스트하십시오.
- 컨텍스트를 사용하는 훅의 경우
wrapper를 전달하십시오.
접근성 어설션
import { axe, toHaveNoViolations } from "jest-axe"; // 또는 vitest-axe
expect.extend(toHaveNoViolations);
test("UserCard has no a11y violations", async () => {
const { container } = render(<UserCard user={mockUser} />);
expect(await axe(container)).toHaveNoViolations();
});
모든 인터랙티브 컴포넌트에 대해 컴포넌트 테스트에서 axe를 실행하십시오. 다음을 발견합니다:
- 폼 입력의 누락된 레이블
- 잘못된 ARIA 사용
- 낮은 색상 대비 (제한적 — JSDOM에는 실제 CSS 엔진이 없으므로 인라인 스타일에만 작동합니다. 시각적 대비는 Playwright에 속합니다.)
- 이미지의 누락된 대체 텍스트
- 제목 순서 위반
교차 참조: skills/accessibility/SKILL.md (광범위한 접근성 테스트 플레이북)
스냅샷 테스트를 사용하지 말아야 할 때
렌더링된 출력의 스냅샷:
- 모든 스타일 변경 시 깨집니다.
- 리뷰 중에 도장 찍히듯 통과됩니다.
- 동작이 아닌 구현 세부 사항(DOM 구조)을 테스트합니다.
허용 가능한 스냅샷 사용:
- 순수 데이터 직렬화 함수 (
formatInvoice(invoice)-> 안정적인 문자열) - 생성된 설정 파일 (예: webpack 설정 출력)
컴포넌트의 시각적 회귀 테스트에는 Playwright/Cypress 스크린샷 또는 Percy/Chromatic을 사용하십시오. DOM 문자열이 아닌 실제 시각적 차이를 확인합니다.
Playwright / Cypress를 사용해야 할 때
JSDOM(Vitest/Jest에서 사용)은 다음을 할 수 없습니다:
- 실제 레이아웃(flexbox, grid, 뷰포트 쿼리)을 렌더링합니다.
- 네이티브 브라우저 애니메이션, CSS 전환을 실행합니다.
- 스크롤 동작, 드래그 앤 드롭, 클립보드 붙여넣기를 테스트합니다.
- iframe, 팝업, 다운로드, 교차 출처 흐름을 처리합니다.
- 완전한 DevTools 지원과 함께 통제된 환경에서 실제 네트워크를 실행합니다.
이러한 경우 Playwright Component Testing (실제 브라우저에서 컴포넌트 테스트) 또는 전체 E2E를 사용하십시오. e2e-testing skill을 참조하십시오.
결정 경계:
- 훅, 프리젠테이셔널 컴포넌트, 로직이 있는 폼 -> RTL
- 레이아웃이 중요하거나 JSDOM에 없는 브라우저 API를 사용하는 컴포넌트 -> Playwright CT
- 여러 페이지에 걸친 전체 사용자 흐름 -> Playwright/Cypress E2E
커버리지 목표
| 레이어 | 목표 |
|---|---|
| 순수 유틸리티 | >=90% |
| 커스텀 훅 | >=85% |
| 프리젠테이셔널 컴포넌트 | >=80% — 라인이 아닌 동작 기준 |
| 컨테이너 컴포넌트 | >=70% — 골든 패스 + 오류 상태 |
| 페이지 | E2E는 별도로 커버; 최소 스모크 테스트 |
vitest.config.ts / jest.config.js를 통해 구성:
// vitest.config.ts
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
}
안티패턴
container.querySelector("...")— 접근성 쿼리를 우회하여 실제 사용자는 실패할 때 테스트가 통과하도록 합니다.- 렌더링 횟수 어설션 — 구현 세부 사항입니다.
jest.mock("react", ...)— 절대 React를 모킹하지 마십시오. 대신 컴포넌트를 리팩터링하십시오.- 기본적으로 자식 컴포넌트 모킹 — 통합이 아닌 격리를 테스트합니다. 자식에 무거운 부수 효과가 있을 때만 모킹하십시오.
act()경고 무시 — 실제 버그(언마운트 후 상태 업데이트, 누락된 비동기 래핑)를 알리는 신호입니다.- 테스트 간에 가변 상태 공유 — 테스트 순서가 변경되면 불안정해집니다.
it.skip()을 제거해도 통과하는 테스트 — 테스트가 실제로 생각하는 대로 어설션하지 않습니다.
TDD 워크플로우
RED -> 다음 요구 사항에 대한 실패하는 테스트 작성
GREEN -> 통과하는 최소한의 컴포넌트 코드 작성
REFACTOR -> 컴포넌트 개선, 테스트는 계속 통과
REPEAT -> 다음 요구 사항
새 컴포넌트의 경우:
- 컴포넌트의 prop 타입과 시그니처를 정의합니다.
- 가장 간단한 경우에 대한 첫 번째 테스트를 작성합니다.
- 올바른 이유로 실패하는지 확인합니다.
- 통과할 수 있을 만큼만 구현합니다.
- 다음 테스트 케이스를 추가합니다.
- 세 번째 유사한 테스트가 패턴을 드러낼 때 리팩터링합니다.
테스트 명령어
# Vitest
vitest # watch 모드
vitest run # 1회 실행
vitest run --coverage # 커버리지 포함
vitest run path/to/file.test.tsx # 단일 파일
# Jest
jest --watch
jest --coverage
jest path/to/file.test.tsx
# CI 모드
CI=true vitest run --coverage
관련
- 규칙: rules/react/testing.md
- 스킬: react-patterns, accessibility, e2e-testing, tdd-workflow
- 에이전트:
react-reviewer(코드 리뷰 중 테스트 품질 검토),tdd-guide(TDD 프로세스 강제) - 명령어:
/react-test,/react-review
예제
MSW와 userEvent를 사용한 폼 제출
test("submits user form and shows success", async () => {
server.use(
http.post("/api/users", () =>
HttpResponse.json({ id: "1", name: "Alice" }, { status: 201 }),
),
);
const user = userEvent.setup();
renderWithProviders(<UserForm />);
await user.type(screen.getByLabelText("Name"), "Alice");
await user.type(screen.getByLabelText("Email"), "[email protected]");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText(/saved successfully/i)).toBeInTheDocument();
});
오류 경계 테스트
function Broken() {
throw new Error("boom");
}
test("error boundary renders fallback", () => {
// 예상되는 throw에 대해 React의 console.error 노이즈를 억제한 후 복원합니다.
// 그래야 스파이가 테스트 간에 누출되어 다른 곳의 실제 오류를 숨기지 않습니다.
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Broken />
</ErrorBoundary>,
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
} finally {
errorSpy.mockRestore();
}
});
Suspense 경계 테스트
test("shows loading then content", async () => {
renderWithProviders(
<Suspense fallback={<div>Loading...</div>}>
<UserDetail id="1" />
</Suspense>,
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});