2 분 소요

개요

테스트는 코드가 올바르게 동작하는지 자동으로 검증하는 작업입니다. 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");


관련 링크