FE Test Generation
$ARGUMENTS 로 전달된 파일을 분석하고 적절한 테스트 코드를 생성한다.
테스트 생성 절차
-
대상 파일 분석: 파일을 읽고 export된 함수/컴포넌트/훅을 파악한다
-
테스트 유형 결정: 파일 유형에 따라 적절한 테스트 전략을 선택한다
-
테스트 파일 생성: co-location 원칙에 따라 동일 디렉토리에 .test.ts(x) 생성
-
실행 확인: 사용자에게 vitest run 실행을 안내한다
파일 유형별 테스트 전략
파일 유형 테스트 도구 테스트 초점
유틸리티 함수 Vitest 입출력, 엣지 케이스, 에러
커스텀 훅 renderHook 상태 변화, 반환값, 사이드이펙트
UI 컴포넌트 RTL + Vitest 렌더링, 인터랙션, 접근성
폼 컴포넌트 RTL + user-event 입력, 유효성 검사, 제출
API 호출 MSW + Vitest 요청/응답, 에러 처리, 로딩 상태
페이지 RTL 통합 렌더링, 라우팅, 데이터 표시
Zustand 스토어 Vitest 상태 변경, 액션, 셀렉터
테스트 코드 컨벤션
기본 구조
import { describe, expect, it, vi, beforeEach } from "vitest";
describe("[테스트 대상]", () => { beforeEach(() => { vi.clearAllMocks(); });
describe("[기능/메서드]", () => { it("[기대 동작을 서술]", () => { // Arrange // Act // Assert }); }); });
유틸리티 함수 테스트
import { describe, expect, it } from "vitest"; import { formatCurrency } from "./formatCurrency";
describe("formatCurrency", () => { it("formats number with comma separators", () => { expect(formatCurrency(1000)).toBe("₩1,000"); });
it("handles zero", () => { expect(formatCurrency(0)).toBe("₩0"); });
it("handles negative numbers", () => { expect(formatCurrency(-500)).toBe("-₩500"); });
it("rounds decimal places", () => { expect(formatCurrency(99.999)).toBe("₩100"); }); });
컴포넌트 테스트
import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { Button } from "./Button";
describe("Button", () => { it("renders with text", () => { render(<Button>Click me</Button>); expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument(); });
it("calls onClick when clicked", async () => { const user = userEvent.setup(); const handleClick = vi.fn(); render(<Button onClick={handleClick}>Click</Button>);
await user.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledOnce();
});
it("is disabled when disabled prop is true", () => { render(<Button disabled>Click</Button>); expect(screen.getByRole("button")).toBeDisabled(); });
it("applies variant classes", () => { render(<Button variant="destructive">Delete</Button>); expect(screen.getByRole("button")).toHaveClass("bg-destructive"); }); });
커스텀 훅 테스트
import { renderHook, act } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { useCounter } from "./useCounter";
describe("useCounter", () => { it("initializes with default value", () => { const { result } = renderHook(() => useCounter()); expect(result.current.count).toBe(0); });
it("initializes with provided value", () => { const { result } = renderHook(() => useCounter(10)); expect(result.current.count).toBe(10); });
it("increments count", () => { const { result } = renderHook(() => useCounter()); act(() => result.current.increment()); expect(result.current.count).toBe(1); });
it("decrements count", () => { const { result } = renderHook(() => useCounter(5)); act(() => result.current.decrement()); expect(result.current.count).toBe(4); }); });
폼 테스트
import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { LoginForm } from "./LoginForm";
describe("LoginForm", () => { const mockSubmit = vi.fn();
it("submits with valid data", async () => { const user = userEvent.setup(); render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText("Email"), "test@example.com");
await user.type(screen.getByLabelText("Password"), "password123");
await user.click(screen.getByRole("button", { name: /submit/i }));
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith({
email: "test@example.com",
password: "password123",
});
});
});
it("shows validation error for invalid email", async () => { const user = userEvent.setup(); render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText("Email"), "invalid");
await user.click(screen.getByRole("button", { name: /submit/i }));
expect(await screen.findByText(/valid email/i)).toBeInTheDocument();
expect(mockSubmit).not.toHaveBeenCalled();
}); });
API 호출 테스트 (MSW)
import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { fetchUsers } from "./api";
const server = setupServer( http.get("/api/users", () => { return HttpResponse.json([ { id: "1", name: "Alice" }, { id: "2", name: "Bob" }, ]); }) );
beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());
describe("fetchUsers", () => { it("returns user list", async () => { const users = await fetchUsers(); expect(users).toHaveLength(2); expect(users[0].name).toBe("Alice"); });
it("handles server error", async () => { server.use( http.get("/api/users", () => { return new HttpResponse(null, { status: 500 }); }) );
await expect(fetchUsers()).rejects.toThrow();
}); });
Zustand 스토어 테스트
import { describe, expect, it, beforeEach } from "vitest"; import { useCartStore } from "./cartStore";
describe("cartStore", () => { beforeEach(() => { useCartStore.setState({ items: [], total: 0 }); });
it("adds item to cart", () => { const { addItem } = useCartStore.getState(); addItem({ id: "1", name: "Product", price: 100 });
const { items } = useCartStore.getState();
expect(items).toHaveLength(1);
expect(items[0].name).toBe("Product");
});
it("calculates total", () => { const { addItem } = useCartStore.getState(); addItem({ id: "1", name: "A", price: 100 }); addItem({ id: "2", name: "B", price: 200 });
expect(useCartStore.getState().total).toBe(300);
}); });
테스트 작성 원칙
-
사용자 관점으로 테스트: 구현 상세가 아닌 동작을 테스트한다
-
접근성 쿼리 우선: getByRole
getByLabelText getByText getByTestId
-
AAA 패턴: Arrange → Act → Assert
-
단일 검증: 하나의 it 에서 하나의 동작만 검증
-
Mocking 최소화: 외부 의존성만 mock, 내부 구현은 mock하지 않음
-
엣지 케이스 포함: 빈 값, null, 에러, 경계값 테스트
실행 규칙
-
인자가 없으면 사용자에게 테스트 대상을 질문한다
-
대상 파일을 먼저 읽고, export된 항목을 파악한다
-
기존 테스트 파일이 있으면 읽고, 누락된 케이스를 추가한다
-
프로젝트의 테스트 설정(vitest.config, setup 파일)을 확인하고 맞춘다
-
@testing-library/jest-dom matchers 사용 가능 여부 확인