프론트엔드 테스팅
사용자가 보고 수행하는 동작을 테스트하고, 컴포넌트가 어떻게 구현되었는지는 테스트하지 마십시오. 루프를 위해 naki-tdd와 함께 사용하고, 전체 브라우저 흐름은 naki-e2e-playwright, 시맨틱 테스트는 naki-accessibility와 함께 사용하세요. 프로젝트에 프로젝트 규칙 스킬이 있다면 이를 따르세요(예: naki-myapp).
사용 시기
- 새로운 컴포넌트, 훅 또는 페이지에 테스트가 필요한 경우.
- 리팩터링 후 동작이 변경되지 않았는데 테스트가 깨지는 경우(구현 결합의 신호).
- 테스트가 불안정하거나,
waitFor를 도처에서 사용하거나, 앱의 절반을 모킹하는 경우.
핵심 규칙
- 사용자처럼 쿼리하세요. 우선순위:
getByRole(name과 함께),getByLabelText,getByPlaceholderText,getByText,getByDisplayValue. 접근 가능한 대안이 없을 때만getByTestId를 사용하세요. fireEvent대신userEvent로 상호작용하세요.const user = userEvent.setup()으로 생성하고 모든 호출을await하세요.- 출력 결과를 단언하세요: 보이는 텍스트, 역할, 폼 값, 네트워크 엔드포인트 호출. 상태 변수, 훅 내부, CSS 클래스 이름 또는 컴포넌트 인스턴스 메서드를 단언하지 마세요.
- 네트워크를 모킹하고, 모듈은 모킹하지 마세요. MSW 핸들러를 HTTP 계층에서 사용하세요. 진정한 외부 경계(시간, 랜덤, jsdom에 없는 브라우저 API)에 대해서만 모듈을 모킹하세요.
- 나중에 나타나는 요소에는
findBy*를 사용하세요.waitFor는 쿼리가 아닌 단언에 사용하고,waitFor내부에 부수 효과를 절대 넣지 마세요. - 테스트당 하나의 동작, 문장 형태로 이름을 지정하세요:
shows an error when the server returns 500. - 각 테스트는 자체 상태를 설정합니다. 순서 의존성 없음;
afterEach에서 핸들러, 타이머, 스토리지를 초기화하세요. - 모킹 대신
renderWithProviders헬퍼를 통해 실제 프로바이더 (라우터, 재시도가 비활성화된 쿼리 클라이언트, 테마)를 제공하세요. - 접근성은 테스트 가능합니다.
getByRole("button", { name: "Save" })가 실패하면 실제 접근성 버그를 의미하는 경우가 많습니다. 주요 화면에 선택적으로vitest-axe검사를 추가하세요. - 커버리지는 신호일 뿐, 목표가 아닙니다. 중요한 분기(에러 상태, 빈 상태, 권한)를 커버하세요.
쿼리 치트시트
| 요소 | 쿼리 |
|---|---|
| 버튼 | getByRole("button", { name: /save/i }) |
| 링크 | getByRole("link", { name: "Docs" }) |
| 레이블이 있는 텍스트 입력 | getByRole("textbox", { name: "Email" }) 또는 getByLabelText("Email") |
| 체크박스 | getByRole("checkbox", { name: "Remember me" }) |
| 제목 | getByRole("heading", { level: 2, name: "Alerts" }) |
| 테이블 행 | getAllByRole("row") 후 within(row) |
| 다이얼로그 | getByRole("dialog", { name: "Delete alert" }) |
| 부재 확인 | expect(queryByText("Error")).not.toBeInTheDocument() |
워크플로우
- 동작을 나열하세요: 렌더링 상태(로딩, 빈 상태, 데이터, 에러)와 상호작용.
- 컴포넌트가 호출하는 엔드포인트에 대한 MSW 핸들러를 추가하세요; 기본값은 성공.
- 동작당 하나의 테스트를 작성하고, 에러 케이스의 경우
server.use(...)로 핸들러를 재정의하세요. npx vitest run path/to/file.test.tsx를 실행한 다음, 전체 스위트를 실행하세요.- 의도적으로 구현을 한 번 중단(한 줄 주석 처리)하여 테스트가 실패할 수 있는지 확인하세요.
설정
// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import { cleanup } from "@testing-library/react";
import { server } from "./src/test/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => { server.resetHandlers(); cleanup(); });
afterAll(() => server.close());
onUnhandledRequest: "error"는 처리되지 않은 요청이 있을 경우 조용히 넘어가지 않고 명시적으로 실패하도록 만듭니다.
예제
import { http, HttpResponse } from "msw";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { server } from "../test/server";
import { AlertList } from "./AlertList";
test("lists alerts and filters by severity", async () => {
server.use(http.get("/api/alerts", () => HttpResponse.json([
{ id: "a1", title: "Brute force on vpn", severity: "high" },
{ id: "a2", title: "New admin login", severity: "low" },
])));
const user = userEvent.setup();
render(<AlertList />);
expect(await screen.findByText("Brute force on vpn")).toBeInTheDocument();
await user.selectOptions(screen.getByRole("combobox", { name: "Severity" }), "high");
expect(screen.queryByText("New admin login")).not.toBeInTheDocument();
});
test("shows a retry button when loading fails", async () => {
server.use(http.get("/api/alerts", () => new HttpResponse(null, { status: 500 })));
render(<AlertList />);
expect(await screen.findByRole("button", { name: /retry/i })).toBeInTheDocument();
});
훅과 시간
- 훅을 사용하는 작은 컴포넌트를 통해 테스트하거나, UI가 없는 경우
renderHook을 사용하여 테스트하세요. - 가짜 타이머:
vi.useFakeTimers()와userEvent.setup({ advanceTimers: vi.advanceTimersByTime });afterEach에서vi.useRealTimers()로 복원하세요. vi.setSystemTime(new Date("2026-01-01T00:00:00Z"))로 날짜를 고정하여 스냅샷과 레이블이 안정적이도록 하세요.
체크리스트
- 쿼리가 역할 또는 레이블 기반입니다.
- 모든
userEvent호출이await됩니다. - 네트워크가 MSW로 모킹되었으며, 처리되지 않은 요청은 실패합니다.
- 로딩, 에러 및 빈 상태가 커버되었습니다.
- 내부 구현 또는 클래스 이름에 대한 단언이 없습니다.
- 테스트가 단독으로 실행되든 어떤 순서로든 통과합니다.
- 출력에
act(...)경고가 없습니다.
일반적인 실수
| 실수 | 해결책 |
|---|---|
모든 곳에서 getByTestId 사용 | 역할/레이블 쿼리; 누락된 경우 컴포넌트에 레이블 추가 |
await waitFor(() => getByText("x")) | await findByText("x") |
타이핑에 fireEvent.change 사용 | await user.type(input, "text") |
useAlerts 훅을 직접 모킹 | HTTP 엔드포인트 모킹 |
| 전체 페이지의 큰 스냅샷 | 중요한 부분에 대한 명시적 단언 |
| 재시도가 활성화된 쿼리 클라이언트로 인한 느린 에러 테스트 | 테스트 프로바이더에서 retry: false |
무시된 act 경고 | findBy*로 비동기 UI 업데이트를 await |