React 테스팅은(는) 무엇을 하나요?
행동 중심의 컴포넌트 테스트, 커스텀 훅 테스트, 접근성 검사, 네트워크 레벨 모킹을 위한 종합적인 React 테스팅 패턴입니다.
활성화 시점
- React 컴포넌트, 커스텀 훅 또는 페이지 테스트 작성
- 레거시 미테스트 컴포넌트에 테스트 커버리지 추가
- Enzyme 또는 클래스 컴포넌트 패턴에서 React Testing Library로 마이그레이션
- 새 React 프로젝트에 Vitest 또는 Jest 설정
- 테스트에서 HTTP 요청 모킹
- 접근성 위반 검사
- 어떤 테스트가 RTL에 속하고 어떤 테스트가 Playwright 컴포넌트 테스트 또는 전체 E2E에 속하는지 결정
핵심 원칙
사용자가 보고 수행하는 것을 테스트하고, 구현 세부 사항은 테스트하지 마세요.
테스트는 다음을 수행해야 합니다:
- 프로덕션에서 사용하는 것과 동일한 프로바이더로 컴포넌트 렌더링
- 접근 가능한 쿼리(role, label)와
userEvent를 통해 상호작용 - 보이는 출력과 관찰 가능한 부수 효과(콜백 호출, 요청 전송) 검증
테스트는 다음을 수행해서는 안 됩니다:
- 컴포넌트 상태, 자식에게 전달된 props, 호출된 훅 검사
- React 자체 또는 프레임워크 훅 모킹
- 사용자에게 영향을 미치지 않는 렌더링 횟수 또는 DOM 구조 검증
라이브러리 선택
| 실행기 | 사용 시기 | 참고 |
|---|---|---|
| Vitest | Vite, Remix, 최신 설정 | 더 빠름, 네이티브 ESM, Jest 호환 API |
| Jest | Next.js, CRA, 기존 저장소 | 많은 React 프로젝트의 기본값 |
| Playwright 컴포넌트 테스팅 | 실제 브라우저 엔진 필요 | JSDOM에 필요한 기능이 없을 때 사용 |
| Cypress 컴포넌트 테스팅 | 실제 브라우저, 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("폼 제출", 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]" });
});
- 항상
awaituserEvent 호출 - 테스트 당 한 번
userEvent.setup()을 호출하고 반환된user재사용 userEvent는 실제 브라우저 시퀀스를 시뮬레이션하고,fireEvent는 단일 합성 이벤트를 발송합니다.userEvent를 선호하세요.
비동기 패턴
// 비동기 작업 후 나타나는 요소
expect(await screen.findByText("Loaded")).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("500 에러 렌더링", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<UserPage id="1" />);
expect(await screen.findByText(/문제가 발생했습니다/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 증가 및 감소", () => {
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 초기값 받기", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test("useUser 사용자 데이터 가져오기", 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에 a11y 위반이 없음", async () => {
const { container } = render(<UserCard user={mockUser} />);
expect(await axe(container)).toHaveNoViolations();
});
모든 상호작용 컴포넌트에 대해 컴포넌트 테스트에서 axe를 실행하세요. 다음을 발견합니다:
- 양식 입력에 레이블 누락
- 잘못된 ARIA 사용
- 낮은 색상 대비 (제한됨 — JSDOM에는 실제 CSS 엔진이 없으므로 인라인 스타일에만 작동합니다. 시각적 대비는 Playwright에 속합니다)
- 이미지에 대체 텍스트 누락
- 제목 순서 위반
교차 링크: skills/accessibility/SKILL.md (광범위한 a11y 테스팅 플레이북)
스냅샷 테스트를 사용하지 말아야 할 때
렌더링된 출력의 스냅샷:
- 모든 스타일 변경 시 실패
- 리뷰 중에 도장 찍기 쉬움
- 구현 세부 사항(DOM 구조)을 테스트하고, 동작을 테스트하지 않음
허용되는 스냅샷 사용:
- 순수 데이터 직렬화 함수 (
formatInvoice(invoice)-> 안정적인 문자열) - 생성된 설정 파일 (예: webpack 설정 출력)
컴포넌트의 시각적 회귀 테스트에는 Playwright/Cypress 스크린샷 또는 Percy/Chromatic을 사용하세요. DOM 문자열이 아닌 실제 시각적 diff를 사용해야 합니다.
Playwright / Cypress를 사용해야 할 때
JSDOM(Vitest/Jest에서 사용)은 다음을 할 수 없습니다:
- 실제 레이아웃 렌더링(flexbox, grid, viewport 쿼리)
- 네이티브 브라우저 애니메이션, CSS 전환 실행
- 스크롤 동작, 드래그 앤 드롭, 클립보드 붙여넣기 테스트
- iframe, 팝업, 다운로드, 교차 출처 흐름 처리
- DevTools의 전체 지원을 받는 통제된 환경에서 실제 네트워크 실행
이러한 경우 Playwright 컴포넌트 테스트(실제 브라우저 내 컴포넌트 테스트) 또는 전체 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 # 감시 모드
vitest run # 한 번 실행
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("사용자 폼 제출 및 성공 메시지 표시", 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(/성공적으로 저장되었습니다/i)).toBeInTheDocument();
});
에러 경계 테스트
function Broken() {
throw new Error("boom");
}
test("에러 경계가 폴백 렌더링", () => {
// 예상되는 에러에 대해 React의 console.error 소음을 억제한 후 복원하여
// 스파이가 테스트 간에 누출되어 다른 곳의 실제 에러를 숨기지 않도록 합니다.
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(
<ErrorBoundary fallback={<div>문제가 발생했습니다</div>}>
<Broken />
</ErrorBoundary>,
);
expect(screen.getByText("문제가 발생했습니다")).toBeInTheDocument();
} finally {
errorSpy.mockRestore();
}
});
Suspense 경계 테스트
test("로딩 후 콘텐츠 표시", async () => {
renderWithProviders(
<Suspense fallback={<div>로딩 중...</div>}>
<UserDetail id="1" />
</Suspense>,
);
expect(screen.getByText("로딩 중...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});