[React] 테스트 (Vitest, Testing Library)
개요
테스트는 코드가 올바르게 동작하는지 자동으로 검증하는 작업입니다. Vitest는 Vite 기반 테스트 러너이고, Testing Library는 실제 사용자 관점에서 컴포넌트를 테스트하는 라이브러리입니다.
설치
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
vite.config.ts 설정
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom", // 브라우저 환경 시뮬레이션
setupFiles: ["./src/setupTests.ts"],
globals: true, // describe, it, expect 자동 import
},
});
setupTests.ts
// src/setupTests.ts
import "@testing-library/jest-dom"; // toBeInTheDocument() 등 matcher 추가
package.json 스크립트 추가
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"coverage": "vitest run --coverage"
}
}
기본 컴포넌트 테스트
// src/components/Greeting.tsx
interface Props {
name: string;
}
function Greeting({ name }: Props) {
return <h1>안녕하세요, {name}님!</h1>;
}
export default Greeting;
// src/components/Greeting.test.tsx
import { render, screen } from "@testing-library/react";
import Greeting from "./Greeting";
describe("Greeting 컴포넌트", () => {
it("이름을 포함한 인사말을 렌더링해야 한다", () => {
render(<Greeting name="Alice" />);
// 화면에 텍스트가 있는지 확인
expect(screen.getByText("안녕하세요, Alice님!")).toBeInTheDocument();
});
it("h1 태그로 렌더링되어야 한다", () => {
render(<Greeting name="Bob" />);
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
});
});
이벤트 테스트
// src/components/Counter.tsx
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p role="status">count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>증가</button>
<button onClick={() => setCount((c) => c - 1)}>감소</button>
</div>
);
}
export default Counter;
// src/components/Counter.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Counter from "./Counter";
describe("Counter 컴포넌트", () => {
it("초기값은 0이어야 한다", () => {
render(<Counter />);
expect(screen.getByRole("status")).toHaveTextContent("count: 0");
});
it("증가 버튼 클릭 시 count가 1 증가해야 한다", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: "증가" }));
expect(screen.getByRole("status")).toHaveTextContent("count: 1");
});
it("감소 버튼 클릭 시 count가 1 감소해야 한다", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: "감소" }));
expect(screen.getByRole("status")).toHaveTextContent("count: -1");
});
});
폼 입력 테스트
// src/components/LoginForm.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LoginForm from "./LoginForm";
describe("LoginForm 컴포넌트", () => {
it("입력 필드가 렌더링되어야 한다", () => {
render(<LoginForm />);
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
expect(screen.getByLabelText("비밀번호")).toBeInTheDocument();
});
it("이메일을 입력할 수 있어야 한다", async () => {
const user = userEvent.setup();
render(<LoginForm />);
const emailInput = screen.getByLabelText("이메일");
await user.type(emailInput, "test@example.com");
expect(emailInput).toHaveValue("test@example.com");
});
it("짧은 비밀번호 입력 후 제출 시 오류 메시지가 표시되어야 한다", async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText("비밀번호"), "123");
await user.click(screen.getByRole("button", { name: "로그인" }));
expect(await screen.findByText("비밀번호는 8자 이상이어야 합니다")).toBeInTheDocument();
});
});
비동기 테스트 (API 모킹)
// src/components/UserList.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import UserList from "./UserList";
// fetch를 모킹
const mockUsers = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
];
beforeEach(() => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: async () => mockUsers,
} as Response);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("UserList 컴포넌트", () => {
it("로딩 후 사용자 목록을 표시해야 한다", async () => {
render(<UserList />);
// 로딩 상태 확인
expect(screen.getByText("로딩 중...")).toBeInTheDocument();
// 데이터 로딩 완료 대기
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
expect(screen.getByText("Bob")).toBeInTheDocument();
});
});
유용한 쿼리 메서드
// 접근성 기반 (권장)
screen.getByRole("button", { name: "확인" }); // 버튼
screen.getByRole("heading", { level: 1 }); // h1
screen.getByRole("textbox"); // input[type=text]
screen.getByRole("link", { name: "홈" });
// 레이블 기반
screen.getByLabelText("이메일"); // label과 연결된 input
// 텍스트 기반
screen.getByText("안녕하세요");
screen.getByText(/안녕/i); // 정규식
// 비동기 (데이터 로딩 후 나타나는 요소)
await screen.findByText("Alice");
await screen.findByRole("list");