Vitest 테스팅 가이드: 제로에서 프로덕션 확신까지
유닛 테스트, 통합 테스트, Testing Library를 활용한 컴포넌트 테스트, 모킹 전략, 커버리지 설정, 그리고 실제로 더 나은 소프트웨어를 만드는 테스팅 철학.
대부분의 팀은 운동하는 것과 비슷하게 테스트를 작성합니다. 해야 한다는 건 알고, 안 하면 죄책감을 느끼고, 마침내 하게 되면 첫날부터 너무 무리해서 금요일쯤이면 포기합니다. 결국 코드베이스에는 CSS 클래스 하나 바꿀 때마다 깨지는 얕은 스냅샷 테스트가 흩어져 있고, 아무도 신뢰하지 않는 통합 테스트 몇 개가 있으며, README의 커버리지 배지는 거짓말을 합니다.
저는 양쪽 다 경험해봤습니다. 테스트 없이 프로젝트를 출시하고 매 배포마다 식은땀을 흘린 적도 있고, 100% 커버리지를 쫓다가 기능 개발보다 테스트 유지보수에 더 많은 시간을 쓴 팀에 있었던 적도 있습니다. 둘 다 제대로 작동하지 않습니다. 효과가 있는 것은 올바른 위치에서 올바른 도구로 올바른 테스트를 작성하는 것입니다.
Vitest는 JavaScript 테스팅에 대한 제 사고방식을 바꿔놓았습니다. 새로운 개념을 발명했기 때문이 아닙니다 — 기본 원리는 Kent Beck이 수십 년 전에 쓴 것에서 변하지 않았습니다. 다만 테스트 작성에서 충분한 마찰을 제거해서 귀찮은 작업이 아니라 개발 루프의 일부처럼 느껴지게 만들었기 때문입니다. 테스트 러너가 개발 서버만큼 빠르고 같은 설정을 사용하면 변명의 여지가 사라집니다.
이 글은 초기 설정부터 모든 것을 가치 있게 만드는 철학까지, Vitest를 활용한 테스팅에 대해 제가 아는 모든 것입니다.
왜 Jest 대신 Vitest인가#
Jest를 사용해봤다면 Vitest API의 대부분을 이미 알고 있습니다. 이것은 의도된 설계입니다 — Vitest는 API 수준에서 Jest와 호환됩니다. describe, it, expect, beforeEach, vi.fn() — 전부 작동합니다. 그러면 왜 전환해야 할까요?
네이티브 ESM 지원#
Jest는 CommonJS를 위해 만들어졌습니다. ESM을 처리할 수 있지만 설정, 실험적 플래그, 그리고 가끔 기도가 필요합니다. import/export 구문을 사용하고 있다면(최신 프로젝트라면 전부 해당됩니다), Jest의 변환 파이프라인과 싸워본 적이 있을 겁니다.
Vitest는 Vite 위에서 실행됩니다. Vite는 ESM을 네이티브로 이해합니다. 소스 코드에 변환 단계가 없습니다 — 그냥 작동합니다. 이건 들리는 것보다 중요합니다. 지난 몇 년간 제가 디버깅한 Jest 문제의 절반은 모듈 해석으로 거슬러 올라갑니다: SyntaxError: Cannot use import statement outside a module, 또는 모듈이 이미 다른 형식으로 캐시되어 목이 작동하지 않는 문제 등입니다.
개발 서버와 같은 설정#
프로젝트가 Vite를 사용한다면(2026년에 React, Vue, 또는 Svelte 앱을 만들고 있다면 아마 사용하고 있을 겁니다), Vitest는 자동으로 vite.config.ts를 읽습니다. 경로 별칭, 플러그인, 환경 변수가 추가 설정 없이 테스트에서 작동합니다. Jest에서는 번들러 설정과 동기화를 유지해야 하는 병렬 설정을 관리합니다. vite.config.ts에 경로 별칭을 추가할 때마다 jest.config.ts에 해당하는 moduleNameMapper를 추가하는 것을 기억해야 합니다. 작은 일이지만 작은 일이 쌓입니다.
속도#
Vitest는 빠릅니다. 의미 있게 빠릅니다. "2초 절약" 수준이 아니라 "작업 방식을 바꾸는" 수준입니다. Vite의 모듈 그래프를 사용하여 파일 변경에 영향을 받는 테스트만 파악하고 그것만 실행합니다. 워치 모드는 Vite 개발 서버를 즉각적으로 느끼게 하는 것과 같은 HMR 인프라를 사용합니다.
400개 이상의 테스트가 있는 프로젝트에서 Jest에서 Vitest로 전환하면 워치 모드 피드백 루프가 약 4초에서 500ms 미만으로 줄었습니다. 이것은 "테스트 통과를 기다려야지"와 "손가락이 아직 키보드 위에 있는 동안 터미널을 흘끗 보면 돼" 사이의 차이입니다.
내장 벤치마킹#
Vitest는 성능 테스팅을 위한 bench()를 기본으로 포함합니다. 별도의 라이브러리가 필요 없습니다:
import { bench, describe } from "vitest";
describe("string concatenation", () => {
bench("template literals", () => {
const name = "world";
const _result = `hello ${name}`;
});
bench("string concat", () => {
const name = "world";
const _result = "hello " + name;
});
});vitest bench로 실행합니다. 주요 기능은 아니지만 benchmark.js를 설치하고 별도의 러너를 구성하지 않아도 같은 툴체인에서 성능 테스팅이 가능하다는 건 좋은 점입니다.
설정#
설치#
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom설정 파일#
프로젝트 루트에 vitest.config.ts를 생성합니다:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
alias: {
"@": path.resolve(__dirname, "./src"),
},
css: false,
},
});또는 이미 vite.config.ts가 있다면 확장할 수 있습니다:
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": "/src",
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});globals: true 결정#
globals가 true이면 describe, it, expect, beforeEach 등을 임포트할 필요가 없습니다 — Jest와 마찬가지로 어디서나 사용 가능합니다. false이면 명시적으로 임포트합니다:
// globals: false
import { describe, it, expect } from "vitest";
describe("math", () => {
it("adds numbers", () => {
expect(1 + 1).toBe(2);
});
});저는 시각적 노이즈를 줄이고 대부분의 개발자가 기대하는 것과 일치하기 때문에 globals: true를 사용합니다. 명시적 임포트를 중시하는 팀이라면 false로 설정하세요 — 여기에 정답은 없습니다.
globals: true를 사용한다면 TypeScript가 인식할 수 있도록 tsconfig.json에 Vitest의 타입을 추가하세요:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}환경: jsdom vs happy-dom vs node#
Vitest는 테스트별 또는 전역으로 DOM 구현을 선택할 수 있게 합니다:
node— DOM 없음. 순수 로직, 유틸리티, API 라우트, 브라우저를 건드리지 않는 모든 것에 적합합니다.jsdom— 표준. 완전한 DOM 구현. 무겁지만 더 완전합니다.happy-dom— jsdom보다 가볍고 빠르지만 덜 완전합니다. 일부 엣지 케이스(Range,Selection, 또는IntersectionObserver같은)가 작동하지 않을 수 있습니다.
저는 전역적으로 jsdom을 기본값으로 하고 node가 필요할 때 파일별로 오버라이드합니다:
// src/lib/utils.test.ts
// @vitest-environment node
import { formatDate, slugify } from "./utils";
describe("slugify", () => {
it("converts spaces to hyphens", () => {
expect(slugify("hello world")).toBe("hello-world");
});
});셋업 파일#
셋업 파일은 모든 테스트 파일 전에 실행됩니다. Testing Library 매처와 전역 목을 설정하는 곳입니다:
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
// Mock IntersectionObserver — jsdom doesn't implement it
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
value: MockIntersectionObserver,
});
// Mock window.matchMedia — needed for responsive components
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});@testing-library/jest-dom/vitest 임포트는 toBeInTheDocument(), toHaveClass(), toBeVisible() 등의 매처를 제공합니다. 이들은 DOM 요소에 대한 단언을 읽기 쉽고 표현력 있게 만들어줍니다.
좋은 테스트 작성하기#
AAA 패턴#
모든 테스트는 같은 구조를 따릅니다: Arrange(준비), Act(실행), Assert(검증). 명시적으로 주석을 달지 않더라도 구조가 보여야 합니다:
it("calculates total with tax", () => {
// Arrange
const items = [
{ name: "Widget", price: 10 },
{ name: "Gadget", price: 20 },
];
const taxRate = 0.08;
// Act
const total = calculateTotal(items, taxRate);
// Assert
expect(total).toBe(32.4);
});준비, 실행, 검증을 하나의 메서드 호출 체인으로 뒤섞는 테스트를 보면, 실패했을 때 이해하기 어렵다는 걸 압니다. 주석을 달지 않더라도 세 단계를 시각적으로 구분해서 유지하세요.
테스트 이름 짓기#
두 가지 유파가 있습니다: it('should calculate total with tax')와 it('calculates total with tax'). "should" 접두사는 정보를 추가하지 않으면서 장황합니다. 테스트가 실패하면 이렇게 표시됩니다:
FAIL ✕ calculates total with tax
이것 자체로 이미 완전한 문장입니다. "should"를 추가하는 건 그저 노이즈입니다. 저는 직접적인 형태를 선호합니다: it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found').
describe 블록에는 테스트 대상 유닛의 이름을 사용합니다:
describe("calculateTotal", () => {
it("sums item prices", () => { /* ... */ });
it("applies tax rate", () => { /* ... */ });
it("returns 0 for empty array", () => { /* ... */ });
it("handles negative prices", () => { /* ... */ });
});소리 내어 읽어보세요: "calculateTotal은 항목 가격을 합산한다." "calculateTotal은 세율을 적용한다." 문장이 자연스러우면 이름이 잘 지어진 것입니다.
테스트당 하나의 단언 vs 실용적 그룹화#
순수주의 규칙은 테스트당 하나의 단언입니다. 실용적 규칙은 테스트당 하나의 개념입니다. 이 둘은 다릅니다.
// This is fine — one concept, multiple assertions
it("formats user display name", () => {
const user = { firstName: "John", lastName: "Doe", title: "Dr." };
const result = formatDisplayName(user);
expect(result).toContain("John");
expect(result).toContain("Doe");
expect(result).toStartWith("Dr.");
});
// This is not fine — multiple concepts in one test
it("handles user operations", () => {
const user = createUser("John");
expect(user.id).toBeDefined();
const updated = updateUser(user.id, { name: "Jane" });
expect(updated.name).toBe("Jane");
deleteUser(user.id);
expect(getUser(user.id)).toBeNull();
});첫 번째 테스트는 세 개의 단언이 있지만 모두 하나의 것을 검증합니다: 표시 이름의 형식. 어떤 단언이 실패하면 정확히 무엇이 잘못되었는지 알 수 있습니다. 두 번째 테스트는 세 개의 별도 테스트를 하나에 억지로 넣은 것입니다. 두 번째 단언이 실패하면 생성이 잘못된 건지 업데이트가 잘못된 건지 알 수 없고, 세 번째 단언은 아예 실행되지 않습니다.
문서화로서의 테스트 설명#
좋은 테스트 스위트는 살아있는 문서화 역할을 합니다. 코드에 익숙하지 않은 사람도 테스트 설명을 읽고 기능의 동작을 이해할 수 있어야 합니다:
describe("PasswordValidator", () => {
describe("minimum length", () => {
it("rejects passwords shorter than 8 characters", () => { /* ... */ });
it("accepts passwords with exactly 8 characters", () => { /* ... */ });
});
describe("character requirements", () => {
it("requires at least one uppercase letter", () => { /* ... */ });
it("requires at least one number", () => { /* ... */ });
it("requires at least one special character", () => { /* ... */ });
});
describe("common password check", () => {
it("rejects passwords in the common passwords list", () => { /* ... */ });
it("performs case-insensitive comparison", () => { /* ... */ });
});
});이 테스트 스위트가 실행되면 출력이 사양서처럼 읽힙니다. 그것이 목표입니다.
모킹#
모킹은 테스팅 도구 중 가장 강력하면서도 가장 위험한 도구입니다. 잘 사용하면 테스트 대상을 격리하고 테스트를 빠르고 결정적으로 만듭니다. 잘못 사용하면 코드가 무엇을 하든 통과하는 테스트를 만들게 됩니다.
vi.fn() — 목 함수 만들기#
가장 간단한 목은 호출을 기록하는 함수입니다:
const mockCallback = vi.fn();
// Call it
mockCallback("hello", 42);
mockCallback("world");
// Assert on calls
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");반환값을 줄 수 있습니다:
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: "Test" }),
});또는 연속 호출에서 다른 값을 반환하게 할 수 있습니다:
const mockRandom = vi.fn()
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.9);vi.spyOn() — 실제 메서드 관찰하기#
동작을 대체하지 않고 메서드를 관찰하고 싶을 때:
const consoleSpy = vi.spyOn(console, "warn");
validateInput("");
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();spyOn은 기본적으로 원래 구현을 유지합니다. 필요할 때 .mockImplementation()으로 오버라이드할 수 있지만 나중에 .mockRestore()로 원래 상태를 복원하세요.
vi.mock() — 모듈 수준 모킹#
이것이 가장 중요합니다. vi.mock()는 전체 모듈을 대체합니다:
// Mock the entire module
vi.mock("@/lib/api", () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]),
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
// The import now uses the mocked version
import { fetchUsers } from "@/lib/api";
describe("UserList", () => {
it("displays users from API", async () => {
const users = await fetchUsers();
expect(users).toHaveLength(2);
});
});Vitest는 vi.mock() 호출을 파일 상단으로 자동 호이스팅합니다. 이는 임포트가 실행되기 전에 목이 적용됨을 의미합니다. 임포트 순서를 걱정할 필요가 없습니다.
자동 모킹#
모든 export를 vi.fn()으로 대체하고 싶다면:
vi.mock("@/lib/analytics");
import { trackEvent, trackPageView } from "@/lib/analytics";
it("tracks form submission", () => {
submitForm();
expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});팩토리 함수 없이 Vitest는 모든 export를 자동 모킹합니다. 각 export된 함수는 undefined를 반환하는 vi.fn()이 됩니다. 이는 모든 함수를 지정하지 않고도 무음 처리하고 싶은 모듈(분석이나 로깅 같은)에 유용합니다.
클리어 vs 리셋 vs 리스토어#
이 부분은 누구나 한 번쯤 헷갈립니다:
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
// mockClear — resets call history, keeps implementation
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // still returns 42
// mockReset — resets call history AND implementation
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // no longer returns 42
// mockRestore — for spies, restores original implementation
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() now works normally again실제로는 beforeEach에서 vi.clearAllMocks()를 사용하여 테스트 간 호출 히스토리를 초기화합니다. spyOn을 사용하고 원래 구현을 복원하고 싶다면 vi.restoreAllMocks()를 사용합니다:
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});과도한 모킹의 위험#
모킹에 대해 제가 줄 수 있는 가장 중요한 조언입니다: 모든 목은 테스트에 하는 거짓말입니다. 의존성을 목할 때, "이것이 올바르게 작동한다고 신뢰하니 단순화된 버전으로 대체하겠다"고 말하는 것입니다. 가정이 틀리면 테스트는 통과하지만 기능은 고장납니다.
// Over-mocked — tests nothing useful
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
it("processes user input", () => {
processInput("hello");
expect(validator.validate).toHaveBeenCalledWith("hello");
expect(formatter.format).toHaveBeenCalledWith("hello");
});이 테스트는 processInput이 validate와 format을 호출하는지 검증합니다. 하지만 processInput이 잘못된 순서로 호출하면? 반환값을 무시하면? 유효성 검사가 포맷 단계의 실행을 막아야 하는데 그렇지 않으면? 테스트는 모릅니다. 흥미로운 동작을 전부 목으로 가려버린 것입니다.
경험적 규칙: 경계에서 목하고, 중간에서 목하지 마세요. 네트워크 요청, 파일 시스템 접근, 서드파티 서비스를 목하세요. 설득력 있는 이유(실행 비용이 비싸거나 부작용이 있는 등)가 없다면 자체 유틸리티 함수를 목하지 마세요.
React 컴포넌트 테스팅#
Testing Library 기본#
Testing Library는 철학을 강제합니다: 사용자가 상호작용하는 방식대로 컴포넌트를 테스트합니다. 내부 상태 확인 없음, 컴포넌트 인스턴스 검사 없음, 얕은 렌더링 없음. 컴포넌트를 렌더링하고 사용자처럼 DOM을 통해 상호작용합니다.
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
describe("Button", () => {
it("renders with label text", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("applies variant classes", () => {
render(<Button variant="primary">Submit</Button>);
const button = screen.getByRole("button");
expect(button).toHaveClass("bg-primary");
});
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
});쿼리: getBy vs queryBy vs findBy#
초보자가 혼란스러워하는 부분입니다. 세 가지 쿼리 변형이 있으며 각각 특정 사용 사례가 있습니다:
getBy* — 요소를 반환하거나 찾지 못하면 에러를 던집니다. 요소가 존재할 것으로 예상할 때 사용합니다:
// Throws if no button found — test fails with a helpful error
const button = screen.getByRole("button", { name: "Submit" });queryBy* — 요소를 반환하거나 찾지 못하면 null을 반환합니다. 무언가가 존재하지 않음을 단언할 때 사용합니다:
// Returns null — doesn't throw
expect(screen.queryByText("Error message")).not.toBeInTheDocument();findBy* — Promise를 반환합니다. 비동기적으로 나타나는 요소에 사용합니다:
// Waits up to 1000ms for the element to appear
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();접근성 우선 쿼리#
Testing Library는 의도적인 우선순위로 쿼리를 제공합니다:
getByRole— 최고의 쿼리. ARIA 역할을 사용합니다. 컴포넌트를 역할로 찾을 수 없다면 접근성 문제가 있을 수 있습니다.getByLabelText— 폼 요소용. 인풋에 레이블이 없다면 먼저 그것을 수정하세요.getByPlaceholderText— 허용되지만 약합니다. 플레이스홀더는 사용자가 입력하면 사라집니다.getByText— 비대화형 요소용. 표시 텍스트로 찾습니다.getByTestId— 최후의 수단. 시맨틱 쿼리가 작동하지 않을 때 사용합니다.
// Prefer this
screen.getByRole("textbox", { name: "Email address" });
// Over this
screen.getByPlaceholderText("Enter your email");
// And definitely over this
screen.getByTestId("email-input");이 순위는 임의적이지 않습니다. 보조 기술이 페이지를 탐색하는 방식과 일치합니다. 역할과 접근 가능한 이름으로 요소를 찾을 수 있다면 스크린 리더도 찾을 수 있습니다. 테스트 ID로만 찾을 수 있다면 접근성 격차가 있을 수 있습니다.
사용자 이벤트#
fireEvent를 사용하지 마세요. @testing-library/user-event를 사용하세요. 차이가 중요합니다:
import userEvent from "@testing-library/user-event";
describe("SearchInput", () => {
it("filters results as user types", async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} />);
const input = screen.getByRole("searchbox");
await user.type(input, "vitest");
// user.type fires keydown, keypress, input, keyup for EACH character
// fireEvent.change just sets the value — skipping realistic event flow
expect(onSearch).toHaveBeenLastCalledWith("vitest");
});
it("clears input on escape key", async () => {
const user = userEvent.setup();
render(<SearchInput onSearch={vi.fn()} />);
const input = screen.getByRole("searchbox");
await user.type(input, "hello");
await user.keyboard("{Escape}");
expect(input).toHaveValue("");
});
});userEvent는 실제 브라우저가 발생시키는 전체 이벤트 체인을 시뮬레이션합니다. fireEvent.change는 단일 합성 이벤트입니다. 컴포넌트가 onKeyDown을 감지하거나 onChange 대신 onInput을 사용한다면 fireEvent.change는 그 핸들러를 트리거하지 않지만 userEvent.type은 트리거합니다.
항상 시작 부분에서 userEvent.setup()을 호출하고 반환된 user 인스턴스를 사용하세요. 이렇게 하면 올바른 이벤트 순서와 상태 추적이 보장됩니다.
컴포넌트 상호작용 테스팅#
현실적인 컴포넌트 테스트는 이렇게 생겼습니다:
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
describe("TodoList", () => {
it("adds a new todo item", async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByRole("textbox", { name: /new todo/i });
const addButton = screen.getByRole("button", { name: /add/i });
await user.type(input, "Write tests");
await user.click(addButton);
expect(screen.getByText("Write tests")).toBeInTheDocument();
expect(input).toHaveValue("");
});
it("marks a todo as completed", async () => {
const user = userEvent.setup();
render(<TodoList initialItems={[{ id: "1", text: "Buy groceries", done: false }]} />);
const checkbox = screen.getByRole("checkbox", { name: /buy groceries/i });
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
it("removes completed items when clear button is clicked", async () => {
const user = userEvent.setup();
render(
<TodoList
initialItems={[
{ id: "1", text: "Done task", done: true },
{ id: "2", text: "Pending task", done: false },
]}
/>
);
await user.click(screen.getByRole("button", { name: /clear completed/i }));
expect(screen.queryByText("Done task")).not.toBeInTheDocument();
expect(screen.getByText("Pending task")).toBeInTheDocument();
});
});주목하세요: 내부 상태 검사 없음, component.setState() 없음, 구현 세부사항 확인 없음. 렌더링하고, 상호작용하고, 사용자가 볼 것에 대해 단언합니다. 컴포넌트가 내부 상태 관리를 useState에서 useReducer로 리팩토링해도 이 테스트들은 여전히 통과합니다. 그것이 핵심입니다.
비동기 코드 테스팅#
waitFor#
컴포넌트가 비동기적으로 업데이트될 때 waitFor는 단언이 통과할 때까지 폴링합니다:
import { render, screen, waitFor } from "@testing-library/react";
it("loads and displays user profile", async () => {
render(<UserProfile userId="123" />);
// Initially shows loading
expect(screen.getByText("Loading...")).toBeInTheDocument();
// Wait for content to appear
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
// Loading indicator should be gone
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});waitFor는 콜백이 통과하거나 타임아웃(기본 1000ms)될 때까지 매 50ms마다(기본값) 재시도합니다. 둘 다 커스터마이즈할 수 있습니다:
await waitFor(
() => expect(screen.getByText("Done")).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
);가짜 타이머#
setTimeout, setInterval, 또는 Date를 사용하는 코드를 테스트할 때 가짜 타이머로 시간을 제어할 수 있습니다:
describe("Debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("delays execution by the specified time", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
it("resets timer on subsequent calls", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
vi.advanceTimersByTime(200);
debounced(); // reset the timer
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
});중요: 항상 afterEach에서 vi.useRealTimers()를 호출하세요. 테스트 간에 누출되는 가짜 타이머는 디버깅할 때 가장 혼란스러운 실패를 일으킵니다.
가짜 타이머와 비동기 렌더링 테스팅#
가짜 타이머와 React 컴포넌트 테스팅을 결합할 때는 주의가 필요합니다. React의 내부 스케줄링은 실제 타이머를 사용하므로, 종종 타이머 진행과 React 업데이트 플러시를 함께 해야 합니다:
import { render, screen, act } from "@testing-library/react";
describe("Notification", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("auto-dismisses after 5 seconds", async () => {
render(<Notification message="Saved!" autoDismiss={5000} />);
expect(screen.getByText("Saved!")).toBeInTheDocument();
// Advance timers inside act() to flush React updates
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
});
});MSW를 활용한 API 모킹#
데이터 페칭 테스팅에서 Mock Service Worker(MSW)는 네트워크 수준에서 네트워크 요청을 가로챕니다. 이는 컴포넌트의 fetch/axios 코드가 프로덕션에서와 정확히 동일하게 실행된다는 의미입니다 — MSW는 네트워크 응답만 대체합니다:
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
const server = setupServer(
http.get("/api/users", () => {
return HttpResponse.json([
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
]);
}),
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: "Alice",
email: "alice@example.com",
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("UserList", () => {
it("displays users from API", async () => {
render(<UserList />);
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(await screen.findByText("Bob")).toBeInTheDocument();
});
it("shows error state when API fails", async () => {
// Override the default handler for this one test
server.use(
http.get("/api/users", () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserList />);
expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
});
});MSW가 fetch나 axios를 직접 모킹하는 것보다 나은 이유는:
- 컴포넌트의 실제 데이터 페칭 코드가 실행됩니다 — 실제 통합을 테스트합니다.
- 테스트별로 핸들러를 오버라이드하여 에러 처리를 테스트할 수 있습니다.
- 같은 핸들러가 테스트와 브라우저 개발 모드(예: Storybook) 모두에서 작동합니다.
통합 테스트#
유닛 테스트 vs 통합 테스트#
유닛 테스트는 단일 함수나 컴포넌트를 격리하고 나머지를 전부 목합니다. 통합 테스트는 여러 유닛이 함께 작동하게 하고 외부 경계(네트워크, 파일 시스템, 데이터베이스)만 목합니다.
사실 프로덕션에서 제가 본 대부분의 버그는 유닛 내부가 아니라 유닛 사이의 경계에서 발생합니다. 함수가 격리 상태에서는 완벽하게 작동하지만 호출자가 약간 다른 형식으로 데이터를 전달해서 실패합니다. 컴포넌트가 목 데이터로는 잘 렌더링되지만 실제 API 응답에 추가 중첩 레벨이 있어서 깨집니다.
통합 테스트가 이런 버그를 잡습니다. 유닛 테스트보다 느리고 실패했을 때 디버깅이 더 어렵지만, 테스트당 더 많은 확신을 줍니다.
여러 컴포넌트를 함께 테스팅#
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
// Only mock the API layer — everything else is real
vi.mock("@/lib/api", () => ({
checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
describe("Shopping Cart Flow", () => {
const renderCart = (initialItems = []) => {
return render(
<CartProvider initialItems={initialItems}>
<ShoppingCart />
</CartProvider>
);
};
it("displays item count and total", () => {
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
{ id: "2", name: "Mouse", price: 49.99, quantity: 2 },
]);
expect(screen.getByText("3 items")).toBeInTheDocument();
expect(screen.getByText("$179.97")).toBeInTheDocument();
});
it("updates quantity and recalculates total", async () => {
const user = userEvent.setup();
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
const incrementButton = screen.getByRole("button", { name: /increase quantity/i });
await user.click(incrementButton);
expect(screen.getByText("$159.98")).toBeInTheDocument();
});
it("completes checkout flow", async () => {
const user = userEvent.setup();
const { checkout } = await import("@/lib/api");
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
await user.click(screen.getByRole("button", { name: /checkout/i }));
expect(checkout).toHaveBeenCalledWith({
items: [{ id: "1", quantity: 1 }],
});
expect(await screen.findByText(/order confirmed/i)).toBeInTheDocument();
expect(screen.getByText("ORD-123")).toBeInTheDocument();
});
});이 테스트에서 ShoppingCart와 CartProvider 그리고 그 내부 컴포넌트들(항목 행, 수량 선택기, 총액 표시)이 전부 실제 코드로 함께 작동합니다. 유일한 목은 API 호출입니다, 테스트에서 실제 네트워크 요청을 하고 싶지 않기 때문입니다.
통합 테스트 vs 유닛 테스트 사용 시점#
유닛 테스트를 사용할 때:
- 로직이 복잡하고 많은 엣지 케이스가 있을 때(날짜 파서, 상태 머신, 계산).
- 특정 함수의 동작에 대한 빠른 피드백이 필요할 때.
- 유닛이 상대적으로 격리되어 있고 다른 유닛에 크게 의존하지 않을 때.
통합 테스트를 사용할 때:
- 여러 컴포넌트가 올바르게 함께 작동해야 할 때(유효성 검사와 제출이 있는 폼).
- 데이터가 여러 레이어를 통해 흐를 때(컨텍스트 → 컴포넌트 → 자식 컴포넌트).
- 함수의 반환값이 아니라 사용자 워크플로를 테스트할 때.
실제로, 건강한 테스트 스위트는 기능에 대한 통합 테스트가 무겁고 복잡한 유틸리티에 대한 유닛 테스트가 있습니다. 컴포넌트 자체는 통합 테스트를 통해 테스트됩니다 — 통합 테스트가 사용하는 모든 작은 컴포넌트에 대해 별도의 유닛 테스트가 필요하지 않습니다.
커버리지#
커버리지 실행#
vitest run --coverage커버리지 프로바이더가 필요합니다. Vitest는 두 가지를 지원합니다:
# V8 — faster, uses V8's built-in coverage
npm install -D @vitest/coverage-v8
# Istanbul — more mature, more configuration options
npm install -D @vitest/coverage-istanbulVitest 설정에서 구성합니다:
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.spec.{ts,tsx}",
"src/test/**",
"src/**/*.d.ts",
"src/**/types.ts",
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});Istanbul vs V8#
V8 커버리지는 V8 엔진의 내장 인스트루멘테이션을 사용합니다. 코드 변환 단계가 없어서 더 빠릅니다. 하지만 특히 ES 모듈 경계 주변의 일부 엣지 케이스에서 정확도가 떨어질 수 있습니다.
Istanbul은 테스트 실행 전에 소스 코드에 카운터를 삽입합니다. 더 느리지만 더 많이 검증되었고 더 정확한 브랜치 커버리지를 제공합니다. CI에서 커버리지 임계값을 적용한다면 Istanbul의 정확도가 중요할 수 있습니다.
저는 로컬 개발에서 V8(더 빠른 피드백)을 사용하고 CI에서 Istanbul(더 정확한 적용)을 사용합니다. 필요하다면 환경별로 다른 프로바이더를 구성할 수 있습니다.
커버리지가 실제로 의미하는 것#
커버리지는 테스트 중에 어떤 코드 라인이 실행되었는지 알려줍니다. 그것뿐입니다. 해당 라인이 올바르게 테스트되었는지는 알려주지 않습니다. 이것을 생각해보세요:
function divide(a: number, b: number): number {
return a / b;
}
it("divides numbers", () => {
divide(10, 2);
// No assertion!
});이 테스트는 divide 함수의 100% 커버리지를 제공합니다. 또한 아무것도 테스트하지 않습니다. divide가 null을 반환하든, 에러를 던지든, 미사일을 발사하든 테스트는 통과합니다.
커버리지는 유용한 부정적 지표입니다: 낮은 커버리지는 확실히 테스트되지 않은 경로가 있다는 의미입니다. 하지만 높은 커버리지가 코드가 잘 테스트되었다는 의미는 아닙니다. 모든 라인이 어떤 테스트에서 실행되었다는 것일 뿐입니다.
라인 vs 브랜치#
라인 커버리지가 가장 일반적인 지표이지만 브랜치 커버리지가 더 가치 있습니다:
function getDiscount(user: User): number {
if (user.isPremium) {
return user.yearsActive > 5 ? 0.2 : 0.1;
}
return 0;
}getDiscount({ isPremium: true, yearsActive: 10 })인 테스트는 모든 라인을 실행(100% 라인 커버리지)하지만 세 개의 브랜치 중 두 개만 테스트합니다. isPremium: false 경로와 yearsActive <= 5 경로는 테스트되지 않습니다.
브랜치 커버리지가 이것을 잡습니다. 조건 로직을 통과하는 모든 가능한 경로를 추적합니다. 커버리지 임계값을 적용할 거라면 브랜치 커버리지를 사용하세요.
생성된 코드 무시하기#
일부 코드는 커버리지에 포함되면 안 됩니다. 생성된 파일, 타입 정의, 설정 — 이런 것들은 가치를 더하지 않으면서 지표를 부풀립니다:
// vitest.config.ts
coverage: {
exclude: [
"src/**/*.d.ts",
"src/**/types.ts",
"src/**/*.stories.tsx",
"src/generated/**",
".velite/**",
],
}소스 코드에서 특정 라인이나 블록을 무시할 수도 있습니다:
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
console.log("Debug info:", data);
}
/* v8 ignore stop */
// Or for Istanbul
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }이것을 아껴서 사용하세요. 큰 코드 덩어리를 무시하고 있다면, 그 덩어리에 테스트가 필요하거나 애초에 커버리지 리포트에 포함되면 안 됩니다.
Next.js 테스팅#
next/navigation 모킹#
useRouter, usePathname, 또는 useSearchParams를 사용하는 Next.js 컴포넌트는 목이 필요합니다:
import { vi } from "vitest";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => "/en/blog",
useSearchParams: () => new URLSearchParams("?page=1"),
useParams: () => ({ locale: "en" }),
}));내비게이션이 호출되었는지 확인해야 하는 테스트의 경우:
import { useRouter } from "next/navigation";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
describe("LogoutButton", () => {
it("redirects to home after logout", async () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
forward: vi.fn(),
});
const user = userEvent.setup();
render(<LogoutButton />);
await user.click(screen.getByRole("button", { name: /log out/i }));
expect(mockPush).toHaveBeenCalledWith("/");
});
});next-intl 모킹#
next-intl을 사용하는 국제화된 컴포넌트의 경우:
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));이것이 가장 간단한 접근법입니다 — 번역이 키 자체를 반환하므로 t("hero.title")은 "hero.title"을 반환합니다. 단언에서 실제 번역된 문자열 대신 번역 키를 확인합니다. 이렇게 하면 테스트가 언어에 독립적이 됩니다.
특정 테스트에서 실제 번역이 필요하다면:
vi.mock("next-intl", () => ({
useTranslations: () => {
const translations: Record<string, string> = {
"hero.title": "Welcome to My Site",
"hero.subtitle": "Building things for the web",
};
return (key: string) => translations[key] ?? key;
},
}));라우트 핸들러 테스팅#
Next.js 라우트 핸들러는 Request를 받아 Response를 반환하는 일반 함수입니다. 테스트하기 간단합니다:
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
describe("GET /api/users", () => {
it("returns users list", async () => {
const request = new NextRequest("http://localhost:3000/api/users");
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
])
);
});
it("supports pagination via search params", async () => {
const request = new NextRequest("http://localhost:3000/api/users?page=2&limit=10");
const response = await GET(request);
const data = await response.json();
expect(data.page).toBe(2);
expect(data.items).toHaveLength(10);
});
});
describe("POST /api/users", () => {
it("creates a new user", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "Alice", email: "alice@test.com" }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.name).toBe("Alice");
});
it("returns 400 for invalid body", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "" }),
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});미들웨어 테스팅#
Next.js 미들웨어는 엣지에서 실행되며 모든 요청을 처리합니다. 함수로 테스트합니다:
import { middleware } from "@/middleware";
import { NextRequest } from "next/server";
function createRequest(path: string, headers: Record<string, string> = {}): NextRequest {
const url = new URL(path, "http://localhost:3000");
return new NextRequest(url, { headers });
}
describe("middleware", () => {
it("redirects unauthenticated users from protected routes", async () => {
const request = createRequest("/dashboard");
const response = await middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/login");
});
it("allows authenticated users through", async () => {
const request = createRequest("/dashboard", {
cookie: "session=valid-token",
});
const response = await middleware(request);
expect(response.status).toBe(200);
});
it("adds security headers", async () => {
const request = createRequest("/");
const response = await middleware(request);
expect(response.headers.get("x-frame-options")).toBe("DENY");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("handles locale detection", async () => {
const request = createRequest("/", {
"accept-language": "tr-TR,tr;q=0.9,en;q=0.8",
});
const response = await middleware(request);
expect(response.headers.get("location")).toContain("/tr");
});
});서버 액션 테스팅#
서버 액션은 서버에서 실행되는 비동기 함수입니다. 일반 함수이므로 직접 테스트할 수 있지만 — 서버 전용 의존성을 목해야 할 수 있습니다:
vi.mock("@/lib/db", () => ({
db: {
user: {
update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
},
},
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
describe("updateProfile", () => {
it("updates user and revalidates profile page", async () => {
const formData = new FormData();
formData.set("name", "New Name");
formData.set("bio", "New bio text");
const result = await updateProfile(formData);
expect(result.success).toBe(true);
expect(revalidatePath).toHaveBeenCalledWith("/profile");
});
it("returns error for invalid data", async () => {
const formData = new FormData();
// Missing required fields
const result = await updateProfile(formData);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});실용적 패턴#
커스텀 렌더 함수#
대부분의 프로젝트에서 모든 컴포넌트를 같은 프로바이더로 감쌀 필요가 있습니다. 커스텀 렌더를 만드세요:
// src/test/utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { CartProvider } from "@/contexts/CartContext";
interface CustomRenderOptions extends RenderOptions {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}
function AllProviders({ children, theme = "light", initialCartItems = [] }: {
children: React.ReactNode;
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}) {
return (
<ThemeProvider defaultTheme={theme}>
<CartProvider initialItems={initialCartItems}>
{children}
</CartProvider>
</ThemeProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
options: CustomRenderOptions = {}
) {
const { theme, initialCartItems, ...renderOptions } = options;
return render(ui, {
wrapper: ({ children }) => (
<AllProviders theme={theme} initialCartItems={initialCartItems}>
{children}
</AllProviders>
),
...renderOptions,
});
}
// Re-export everything from testing library
export * from "@testing-library/react";
export { renderWithProviders as render };이제 모든 테스트 파일이 @testing-library/react 대신 커스텀 유틸에서 임포트합니다:
import { render, screen } from "@/test/utils";
it("renders in dark mode", () => {
render(<Header />, { theme: "dark" });
// Header and all its children have access to ThemeProvider and CartProvider
});커스텀 훅 테스팅#
Vitest는 @testing-library/react의 renderHook과 함께 작동합니다:
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
describe("useCounter", () => {
it("starts at initial value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it("increments", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("decrements with floor", () => {
const { result } = renderHook(() => useCounter(0, { min: 0 }));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0); // doesn't go below min
});
});에러 바운더리 테스팅#
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
const ThrowingComponent = () => {
throw new Error("Test explosion");
};
describe("ErrorBoundary", () => {
// Suppress console.error for expected errors
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("displays fallback UI when child throws", () => {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("renders children when no error", () => {
render(
<ErrorBoundary fallback={<div>Error</div>}>
<div>All good</div>
</ErrorBoundary>
);
expect(screen.getByText("All good")).toBeInTheDocument();
expect(screen.queryByText("Error")).not.toBeInTheDocument();
});
});스냅샷 테스팅 (주의해서)#
스냅샷 테스트는 사람들이 실제 단언의 대체물로 사용하기 때문에 안 좋은 평판을 갖고 있습니다. 컴포넌트의 전체 HTML 출력에 대한 스냅샷은 유지보수 부담입니다 — CSS 클래스가 변경될 때마다 깨지고 아무도 diff를 주의 깊게 검토하지 않습니다.
하지만 타겟팅된 스냅샷은 유용할 수 있습니다:
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
// Good — small, targeted snapshot of a pure function's output
it("formats various currency values consistently", () => {
expect(formatCurrency(0)).toMatchInlineSnapshot('"$0.00"');
expect(formatCurrency(1234.5)).toMatchInlineSnapshot('"$1,234.50"');
expect(formatCurrency(-99.99)).toMatchInlineSnapshot('"-$99.99"');
expect(formatCurrency(1000000)).toMatchInlineSnapshot('"$1,000,000.00"');
});
// Bad — giant snapshot nobody will review
it("renders the dashboard", () => {
const { container } = render(<Dashboard />);
expect(container).toMatchSnapshot(); // Don't do this
});인라인 스냅샷(toMatchInlineSnapshot)은 기대값이 테스트에 바로 보이기 때문에 파일 스냅샷보다 낫습니다. 별도의 .snap 파일을 열지 않고도 함수가 무엇을 반환하는지 한눈에 볼 수 있습니다.
테스팅 철학#
구현이 아닌 동작을 테스트하라#
이 원칙은 전용 섹션을 둘 만큼 중요합니다. 같은 기능에 대한 두 가지 테스트를 생각해보세요:
// Implementation test — brittle, breaks on refactors
it("calls setState with new count", () => {
const setStateSpy = vi.spyOn(React, "useState");
render(<Counter />);
fireEvent.click(screen.getByText("+"));
expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
// Behavior test — resilient, tests what the user sees
it("increments the displayed count when plus button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "+" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});첫 번째 테스트는 useState에서 useReducer로 전환하면 깨집니다, 컴포넌트가 정확히 같은 방식으로 작동하더라도. 두 번째 테스트는 컴포넌트의 동작이 실제로 변경될 때만 깨집니다. 카운트가 내부적으로 어떻게 관리되는지 상관하지 않습니다 — "+"를 클릭하면 숫자가 올라가는지만 확인합니다.
판단 기준은 간단합니다: 테스트를 변경하지 않고 구현을 리팩토링할 수 있나요? 그렇다면 동작을 테스트하고 있는 것입니다. 아니라면 구현을 테스트하고 있는 것입니다.
테스팅 트로피#
Kent C. Dodds는 전통적인 테스팅 피라미드의 대안으로 "테스팅 트로피"를 제안했습니다:
╭─────────╮
│ E2E │ 소수 — 비용 높음, 느림, 높은 확신
├─────────┤
│ │
│ 통합 │ 대다수 — 좋은 확신 대비 비용 비율
│ │
├─────────┤
│ 유닛 │ 일부 — 빠름, 집중적, 저비용
├─────────┤
│ 정적 │ 항상 — TypeScript, ESLint
╰─────────╯
전통적인 피라미드는 유닛 테스트를 맨 아래(많이)에, 통합 테스트를 중간(더 적게)에 놓습니다. 트로피는 이를 뒤집습니다: 통합 테스트가 최적점입니다. 그 이유는 다음과 같습니다:
- 정적 분석(TypeScript, ESLint)은 오타, 잘못된 타입, 간단한 논리 오류를 무료로 잡습니다. 아무것도 실행할 필요 없습니다.
- 유닛 테스트는 복잡한 순수 로직에 훌륭하지만 조각들이 함께 작동하는지는 알려주지 않습니다.
- 통합 테스트는 컴포넌트, 훅, 컨텍스트가 함께 작동하는지 확인합니다. 작성한 테스트당 가장 많은 확신을 줍니다.
- E2E 테스트는 전체 시스템을 검증하지만 느리고, 불안정하며, 유지보수 비용이 높습니다. 핵심 경로에 몇 개가 필요하지만 수백 개는 아닙니다.
저는 실제로 이런 분배를 따릅니다: TypeScript가 대부분의 타입 에러를 잡고, 복잡한 유틸리티와 알고리즘에 유닛 테스트를 작성하고, 기능과 사용자 플로에 통합 테스트를 작성하고, 핵심 경로(가입, 구매, 핵심 워크플로)에 소수의 E2E 테스트를 작성합니다.
확신을 주는 것 vs 시간을 낭비하는 것#
테스트는 출시에 대한 확신을 주기 위해 존재합니다. 모든 코드 라인이 실행된다는 확신이 아니라 — 사용자를 위해 애플리케이션이 작동한다는 확신입니다. 이 둘은 다릅니다.
높은 확신, 높은 가치:
- 결제 플로의 통합 테스트 — 폼 유효성 검사, API 호출, 상태 업데이트, 성공/에러 UI를 포함합니다.
- 엣지 케이스가 있는 가격 계산 함수의 유닛 테스트 — 부동소수점, 반올림, 할인, 0/음수 값.
- 보호된 라우트가 인증되지 않은 사용자를 리다이렉트하는지 확인하는 테스트.
낮은 확신, 시간 낭비:
- 정적 마케팅 페이지의 스냅샷 테스트 — 문구가 바뀔 때마다 깨지고, 의미 있는 것을 잡지 못합니다.
- 컴포넌트가 자식에게 prop을 전달하는 유닛 테스트 — 여러분의 코드가 아니라 React 자체를 테스트합니다.
useState가 호출되는지 확인하는 테스트 — 동작이 아니라 프레임워크를 테스트합니다.- 설정 파일의 100% 커버리지 — 정적 데이터이고, TypeScript가 이미 그 형태를 검증합니다.
테스트를 작성하기 전에 물어볼 질문: "이 테스트가 없다면 어떤 버그가 프로덕션에 빠져나갈 수 있을까?" 답이 "TypeScript가 잡지 못할 것은 없다" 또는 "누구도 알아차리지 못할 것"이라면, 그 테스트는 작성할 가치가 없을 것입니다.
설계 피드백으로서의 테스팅#
테스트하기 어려운 코드는 보통 설계가 좋지 않은 코드입니다. 하나의 함수를 테스트하기 위해 다섯 가지를 목해야 한다면, 그 함수는 너무 많은 의존성을 가지고 있습니다. 정교한 컨텍스트 프로바이더를 설정하지 않으면 컴포넌트를 렌더링할 수 없다면, 컴포넌트가 환경에 너무 결합되어 있습니다.
테스트는 여러분 코드의 사용자입니다. 테스트가 여러분의 API를 사용하기 힘들어한다면 다른 개발자도 마찬가지입니다. 테스트 설정과 싸우고 있다면, 더 많은 목을 추가하는 것이 아니라 테스트 대상 코드를 리팩토링하라는 신호로 받아들이세요.
// Hard to test — function does too much
async function processOrder(orderId: string) {
const order = await db.orders.findById(orderId);
const user = await db.users.findById(order.userId);
const inventory = await checkInventory(order.items);
if (!inventory.available) {
await sendEmail(user.email, "out-of-stock", { items: inventory.unavailable });
return { success: false, reason: "out-of-stock" };
}
const payment = await chargeCard(user.paymentMethod, order.total);
if (!payment.success) {
await sendEmail(user.email, "payment-failed", { error: payment.error });
return { success: false, reason: "payment-failed" };
}
await db.orders.update(orderId, { status: "confirmed" });
await sendEmail(user.email, "order-confirmed", { orderId });
return { success: true };
}
// Easier to test — separated concerns
function determineOrderAction(
inventory: InventoryResult,
payment: PaymentResult
): OrderAction {
if (!inventory.available) return { type: "out-of-stock", items: inventory.unavailable };
if (!payment.success) return { type: "payment-failed", error: payment.error };
return { type: "confirmed" };
}두 번째 버전은 순수 함수입니다. 데이터베이스, 결제 프로바이더, 이메일 서비스를 목하지 않고도 모든 인벤토리와 결제 결과 조합을 테스트할 수 있습니다. 오케스트레이션 로직(데이터 가져오기, 이메일 발송)은 통합 수준에서 테스트되는 얇은 레이어에 존재합니다.
이것이 테스팅의 진정한 가치입니다: 버그가 작성된 후에 잡는 것이 아니라, 나쁜 설계가 커밋되기 전에 방지하는 것입니다. 테스트 작성의 규율은 더 작은 함수, 더 명확한 인터페이스, 더 모듈화된 아키텍처를 향하게 합니다. 테스트는 부산물입니다. 설계 개선이 본질입니다.
신뢰할 수 있는 테스트를 작성하라#
테스트 스위트에 일어날 수 있는 최악의 일은 빈틈이 있는 것이 아닙니다. 사람들이 테스트를 더 이상 신뢰하지 않게 되는 것입니다. CI에서 무작위로 실패하는 몇 개의 불안정한 테스트가 있는 테스트 스위트는 팀에게 빨간 빌드를 무시하도록 가르칩니다. 그렇게 되면 테스트 스위트는 무용지물보다 나쁩니다 — 적극적으로 거짓 안전감을 제공합니다.
테스트가 간헐적으로 실패하면 고치거나 삭제하세요. 테스트가 느리면 속도를 높이거나 별도의 느린 테스트 스위트로 옮기세요. 테스트가 관련 없는 변경마다 깨진다면 구현이 아닌 동작을 테스트하도록 다시 작성하세요.
목표는 모든 실패가 실제로 무언가가 고장났다는 의미인 테스트 스위트입니다. 개발자가 테스트를 신뢰하면 매 커밋 전에 테스트를 실행합니다. 신뢰하지 않으면 --no-verify로 우회하고 손가락을 교차한 채 배포합니다.
주말을 걸 수 있는 테스트 스위트를 구축하세요. 그 이하는 유지보수할 가치가 없습니다.