프론트엔드 테스트 완전 정복: 테스트가 뭔지도 모르는 당신을 위한 가이드

프론트엔드 테스트 완전 정복: 테스트가 뭔지도 모르는 당신을 위한 가이드

프론트엔드 테스트 완전 정복: 테스트가 뭔지도 모르는 당신을 위한 가이드

"테스트 코드 작성해주세요" 라는 말에 막막했던 주니어 개발자를 위한 A to Z 가이드

📌 이 글의 목표

  • Vitest + React Testing Library로 첫 테스트를 작성할 수 있게 된다
  • 테스트 도구의 핵심 개념과 문법을 이해한다
  • 컴포넌트·훅·API 호출을 직접 테스트할 수 있게 된다

🚫 이 글에서 다루지 않는 것

  • E2E 테스트 (Playwright, Cypress 등)
  • 스냅샷 테스트, 시각적 회귀 테스트
  • CI/CD 파이프라인 구성, 테스트 커버리지 목표 설정

⚡ 10분 Quick Start

글이 길어서 중간에 지칠 수 있습니다. 먼저 10분 만에 첫 테스트를 통과해보고, 나머지는 필요할 때 읽으세요!

⚠️ 이 Quick Start는 Vite + React 기준입니다. Next.js(App Router)는 하단 부록을 참고하세요.

1. 설치 (1분)

pnpm add -D vitest @vitest/ui @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

2. 설정 파일 2개 만들기 (2분)

// vitest.config.ts import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], test: { globals: true, environment: "jsdom", setupFiles: ["./src/setupTests.ts"], }, });
// src/setupTests.ts import "@testing-library/jest-dom/vitest";

3. 첫 테스트 작성 (3분)

// src/components/Button.test.tsx import { render, screen } from '@testing-library/react'; function Button({ label }: { label: string }) { return <button>{label}</button>; } describe('Button', () => { it('라벨이 표시된다', () => { render(<Button label="클릭" />); expect(screen.getByRole('button')).toHaveTextContent('클릭'); }); });

4. package.json 스크립트 추가 (30초)

{ "scripts": { "test": "vitest", "test:ui": "vitest --ui" } }

5. 실행 (30초)

pnpm test # watch 모드 pnpm test:ui # 브라우저 UI 모드 (@vitest/ui 필요)

💡 스크립트 없이 바로 실행하려면 npx vitest도 가능합니다.

 ✓ src/components/Button.test.tsx
   ✓ Button
     ✓ 라벨이 표시된다

 Test Files  1 passed

🎉 축하합니다! 첫 테스트 통과! 이제 아래 내용을 천천히 읽어보세요.


목차

Part 1: 테스트가 뭔데?

  1. 테스트란 무엇인가
  2. 왜 테스트를 해야 하는가
  3. 테스트의 종류
  4. 프론트엔드 테스트 도구 생태계

Part 2: 환경 세팅하기

  1. 필요한 패키지 설치
  2. 설정 파일 만들기
  3. 첫 테스트 실행해보기

Part 3: 테스트 문법 배우기

  1. describe, it, expect 이해하기
  2. 자주 쓰는 매처(Matcher)
  3. 테스트 전후 처리: beforeEach, afterEach

Part 4: React 컴포넌트 테스트하기

  1. render와 screen 이해하기
  2. 요소 찾기: 쿼리 메서드
  3. 사용자 행동 시뮬레이션
  4. 비동기 테스트: 기다리기

Part 5: 고급 테스트 기법

  1. Mock이란 무엇인가
  2. API 호출 Mocking
  3. 커스텀 훅 테스트
  4. Context와 Provider 테스트

Part 6: 실전 적용

  1. 무엇을 테스트해야 하는가
  2. API Mocking 전략 선택하기
  3. 실제 프로젝트 테스트 예시
  4. 테스트 작성 팁과 주의사항

Part 1: 테스트가 뭔데?

1. 테스트란 무엇인가

일상에서의 테스트

우리는 이미 일상에서 "테스트"를 하고 있습니다.

  • 요리할 때 간을 보는 것
  • 새 신발을 신어보는 것
  • 자동차 시승해보는 것

모두 "내가 원하는 대로 동작하는지" 확인하는 행위입니다.

코드에서의 테스트

코드 테스트도 똑같습니다. "내가 작성한 코드가 의도한 대로 동작하는지" 확인하는 것입니다.

// 내가 만든 함수 function add(a, b) { return a + b; } // 테스트: 이 함수가 제대로 동작하는지 확인 console.log(add(1, 2) === 3); // true면 성공! console.log(add(5, 5) === 10); // true면 성공!

위 코드가 바로 가장 원시적인 형태의 테스트입니다!

테스트 코드 vs console.log

여러분은 이미 테스트를 하고 있었습니다. 바로 console.log로요.

function calculateDiscount(price, rate) { return price * (1 - rate); } // 여러분이 평소에 하던 것 console.log(calculateDiscount(10000, 0.1)); // 9000 나오나 확인 // 테스트 코드는 이걸 자동화한 것 test("10% 할인이 올바르게 계산된다", () => { expect(calculateDiscount(10000, 0.1)).toBe(9000); });

차이점:

  • console.log: 눈으로 직접 확인해야 함, 매번 수동으로 실행
  • 테스트 코드: 자동으로 확인, 한 번에 모든 케이스 실행

2. 왜 테스트를 해야 하는가

테스트 없이 개발하면 생기는 일

시나리오: 로그인 기능을 만들었다고 가정합니다.

1주차: 로그인 기능 완성! 잘 동작함 ✅
2주차: 회원가입 기능 추가. 로그인? 당연히 되겠지...
3주차: 비밀번호 찾기 추가. 로그인은 건드리지도 않았는데?
4주차: "로그인이 안 돼요" 버그 리포트 😱

문제: 새 기능을 추가할 때마다 기존 기능이 깨지지 않았는지 일일이 손으로 확인해야 합니다.

테스트가 있으면

1주차: 로그인 기능 + 테스트 코드 작성
2주차: 회원가입 추가 → 테스트 실행 → 모두 통과 ✅
3주차: 비밀번호 찾기 추가 → 테스트 실행 → 로그인 테스트 실패! 😮
       → 바로 문제 발견하고 수정
4주차: 버그 없이 안정적으로 운영 🎉

테스트의 실질적인 장점

  1. 자신감 있는 코드 수정

    • "이거 수정해도 다른 데 영향 없을까?" 걱정 끝
    • 테스트 통과하면 안심하고 배포
  2. 디버깅 시간 단축

    • 어디서 문제가 생겼는지 바로 파악
    • "새로 추가한 코드 때문에 A 테스트가 깨졌네"
  3. 문서 역할

    • 테스트 코드를 보면 "이 함수가 뭘 해야 하는지" 알 수 있음
    • 새 팀원이 코드 이해하기 쉬움
  4. 더 나은 설계 유도

    • 테스트하기 어려운 코드 = 설계가 안 좋은 코드
    • 테스트를 작성하다 보면 자연스럽게 좋은 구조로

"그래도 시간이 없는데요..."

처음엔 테스트 작성에 시간이 더 걸립니다. 하지만:

테스트 없이 개발:
- 기능 개발: 2시간
- 수동 테스트: 30분
- 버그 발견 후 수정: 2시간
- 다시 수동 테스트: 30분
- 총: 5시간

테스트와 함께 개발:
- 기능 개발 + 테스트: 3시간
- 테스트 실행: 2초
- 버그 수정: 30분 (어디가 문제인지 바로 앎)
- 테스트 재실행: 2초
- 총: 3.5시간

장기적으로 보면 테스트가 시간을 절약합니다.


3. 테스트의 종류

테스트 피라미드

        /\
       /  \      E2E 테스트 (적게)
      /----\     - 실제 브라우저에서 전체 흐름 테스트
     /      \    - 느리고 비쌈
    /--------\
   /          \  통합 테스트 (적당히)
  /------------\ - 여러 컴포넌트가 함께 동작하는지
 /              \
/----------------\  단위 테스트 (많이)
                   - 작은 단위(함수, 컴포넌트) 테스트
                   - 빠르고 쌈

1) 단위 테스트 (Unit Test)

가장 작은 단위를 테스트합니다.

// 함수 하나 테스트 function formatPrice(price) { return price.toLocaleString() + ""; } test("가격이 올바르게 포맷된다", () => { expect(formatPrice(1000)).toBe("1,000원"); expect(formatPrice(50000)).toBe("50,000원"); });
// 컴포넌트 하나 테스트 function Button({ label }) { return <button>{label}</button>; } test("버튼에 라벨이 표시된다", () => { render(<Button label="클릭" />); expect(screen.getByRole("button")).toHaveTextContent("클릭"); });

특징:

  • 빠름 (밀리초 단위)
  • 문제 발생 위치 정확히 파악 가능
  • 가장 많이 작성해야 함

2) 통합 테스트 (Integration Test)

여러 단위가 함께 동작하는지 테스트합니다.

⚠️ 용어 정리: "통합 테스트"는 범위에 따라 다르게 부를 수 있습니다

| 범위 | 예시 | 도구 | | ----------------- | --------------------- | ------------------------- | | 컴포넌트 통합 | 폼 + 입력 + 버튼 조합 | RTL (이 글에서 다루는 것) | | 시스템 통합 | 실제 API + DB + 인증 | E2E (Playwright 등) |

이 글에서 "통합 테스트"는 React 컴포넌트 간 조합을 의미합니다. 실제 서버/DB와 연동하는 테스트는 E2E에 가깝습니다.

// 로그인 폼 전체 테스트 (컴포넌트 통합) test("아이디와 비밀번호를 입력하고 로그인 버튼을 누르면 로그인된다", async () => { render(<LoginForm />); // 입력 await user.type(screen.getByLabelText("아이디"), "testuser"); await user.type(screen.getByLabelText("비밀번호"), "password123"); // 버튼 클릭 await user.click(screen.getByRole("button", { name: "로그인" })); // 결과 확인 expect(screen.getByText("환영합니다")).toBeInTheDocument(); });

특징:

  • 실제 사용 시나리오와 유사
  • 단위 테스트보다 느림
  • 컴포넌트 간 연동 문제 발견 가능
  • API는 Mock으로 대체 (실제 서버 호출 X)

3) E2E 테스트 (End-to-End Test)

실제 브라우저에서 사용자처럼 테스트합니다.

// Playwright나 Cypress 사용 test("회원가입부터 로그인까지", async ({ page }) => { // 회원가입 페이지로 이동 await page.goto("/signup"); // 정보 입력 await page.fill("#email", "test@example.com"); await page.fill("#password", "password123"); await page.click('button[type="submit"]'); // 로그인 페이지로 리다이렉트 확인 await expect(page).toHaveURL("/login"); // 로그인 await page.fill("#email", "test@example.com"); await page.fill("#password", "password123"); await page.click('button[type="submit"]'); // 메인 페이지 확인 await expect(page).toHaveURL("/"); });

특징:

  • 가장 실제와 유사
  • 가장 느림 (초~분 단위)
  • 설정이 복잡함
  • 핵심 플로우만 테스트

이 글에서 다루는 것

이 글에서는 단위 테스트통합 테스트를 다룹니다.

  • 도구: Vitest + React Testing Library
  • 대부분의 프론트엔드 테스트는 이 조합으로 충분합니다.

4. 프론트엔드 테스트 도구 생태계

테스트 러너 (Test Runner)

테스트 파일을 찾아서 실행하고 결과를 보여주는 도구입니다.

| 도구 | 특징 | | ---------- | --------------------------------- | | Jest | 가장 많이 사용, React 공식 추천 | | Vitest | Vite 프로젝트에 최적화, 매우 빠름 | | Mocha | 오래된 도구, 유연함 |

우리의 선택: Vitest

  • Vite 프로젝트라면 설정 그대로 재사용
  • Jest보다 빠름
  • Jest 문법과 거의 동일 (학습 비용 낮음)

💡 Next.js 사용자는 부록: Next.js App Router 테스트 설정을 참고하세요.

테스팅 라이브러리 (Testing Library)

컴포넌트를 렌더링하고 조작하는 도구입니다.

| 도구 | 특징 | | ------------------------- | --------------------------------- | | React Testing Library | 사용자 관점 테스트, 현재 표준 | | Enzyme | 내부 구현 테스트, 현재는 잘 안 씀 |

우리의 선택: React Testing Library

  • "사용자가 보는 것"을 테스트
  • 접근성 좋은 코드를 유도

DOM 환경

Node.js에서는 브라우저의 document, window가 없습니다. 이를 시뮬레이션하는 도구가 필요합니다.

| 도구 | 특징 | | --------- | -------------- | | jsdom | 가장 많이 사용 | | happy-dom | jsdom보다 빠름 |

우리의 선택: jsdom

최종 조합

Vitest (테스트 러너)
  + React Testing Library (컴포넌트 테스트)
  + jsdom (브라우저 환경 시뮬레이션)
  + @testing-library/jest-dom (편리한 매처)
  + @testing-library/user-event (사용자 이벤트)

Part 2: 환경 세팅하기

5. 필요한 패키지 설치

5.1 패키지 설명

# 1. 테스트 러너 pnpm add -D vitest # 2. 테스트 시각화 UI (선택사항이지만 강력 추천) pnpm add -D @vitest/ui # 3. 브라우저 환경 시뮬레이션 pnpm add -D jsdom # 4. React 컴포넌트 테스트 pnpm add -D @testing-library/react # 5. 편리한 DOM 매처 (.toBeInTheDocument() ) pnpm add -D @testing-library/jest-dom # 6. 사용자 이벤트 시뮬레이션 (클릭, 타이핑 ) pnpm add -D @testing-library/user-event

5.2 한 번에 설치

pnpm add -D vitest @vitest/ui jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event # 커버리지 리포트를 사용하려면 추가 설치 필요 pnpm add -D @vitest/coverage-v8

5.3 각 패키지가 하는 일

vitest
├── 테스트 파일을 찾음 (*.test.ts, *.spec.ts)
├── 테스트를 실행함
├── 결과를 보여줌 (통과/실패)
└── watch 모드 지원 (파일 변경 시 자동 재실행)

@vitest/ui
└── 브라우저에서 테스트 결과를 시각적으로 확인

jsdom
└── Node.js에서 document, window 등 브라우저 API 사용 가능하게 함

@testing-library/react
├── render() - 컴포넌트를 가상 DOM에 렌더링
├── screen - 렌더링된 요소를 찾음
└── cleanup() - 테스트 후 정리

@testing-library/jest-dom
└── expect(element).toBeInTheDocument() 같은 편리한 매처 제공

@testing-library/user-event
├── user.click() - 클릭 시뮬레이션
├── user.type() - 타이핑 시뮬레이션
└── user.hover() - 호버 시뮬레이션 등

6. 설정 파일 만들기

6.1 vitest.config.ts

프로젝트 루트에 vitest.config.ts 파일을 만듭니다:

import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import path from "node:path"; import { fileURLToPath } from "node:url"; export default defineConfig({ plugins: [react()], resolve: { alias: { // ESM 환경에서 안전한 방식 (Windows 포함 모든 OS에서 동작) "@": path.resolve(path.dirname(fileURLToPath(import.meta.url)), "./src"), }, }, test: { // 1. 전역 API 사용 (import 없이 describe, it, expect 사용) globals: true, // 2. 브라우저 환경 시뮬레이션 environment: "jsdom", // 3. 테스트 전에 실행할 설정 파일 setupFiles: ["./src/setupTests.ts"], // 4. CSS 지원 (CSS Modules 등에서 에러 나면 켜세요) // css: true, }, });

참고: __dirname은 CommonJS 전용이라 ESM 환경에서 에러가 납니다. fileURLToPath + path.resolve 조합이 Windows를 포함한 모든 OS에서 안전합니다.

각 옵션 설명:

globals: true;

이 옵션이 없으면:

import { describe, it, expect } from 'vitest'; // 매번 import 필요 describe('...', () => { it('...', () => { expect(...); }); });

이 옵션이 있으면:

// import 없이 바로 사용 가능! describe('...', () => { it('...', () => { expect(...); }); });
environment: "jsdom";
// jsdom 없으면 에러! document.createElement("div"); // ❌ document is not defined // jsdom 있으면 동작! document.createElement("div"); // ✅ 정상 동작

6.2 src/setupTests.ts

src 폴더에 setupTests.ts 파일을 만듭니다:

// setupTests.ts import "@testing-library/jest-dom/vitest";

이 한 줄이면 기본 설정 완료입니다!

파일 확장자: .ts vs .tsx

| 프로젝트 | 파일명 | 이유 | | ---------------------------------- | ---------------- | -------------------------------- | | Vite + React (일반) | setupTests.ts | JSX 없음, .ts로 충분 | | Next.js (next/image mock 필요) | setupTests.tsx | JSX로 <img> 반환하는 mock 작성 |

Next.js에서 next/image를 mock할 때 JSX를 사용하므로 .tsx 확장자가 필요합니다. 자세한 내용은 부록: Next.js App Router 테스트를 참고하세요.

팀 컨벤션 권장: vi만 명시적 import

globals: true + types: ["vitest/globals"] 설정이면 describe, it, expect, vi 모두 전역으로 사용 가능합니다.

다만 팀에서는 "mock을 쓰는 테스트임을 드러내기 위해" vi만 명시적으로 import하는 컨벤션을 추천합니다:

// ✅ 권장 패턴 import { vi } from 'vitest'; // vi만 명시적 import describe('MyComponent', () => { it('버튼이 동작한다', () => { const mockFn = vi.fn(); // "이 테스트는 mock을 쓴다"는 신호 expect(mockFn).not.toHaveBeenCalled(); }); });

왜 vi만 import하나요?

  • describe/it/expect는 거의 모든 테스트에서 사용 → global이 편리
  • vi는 mock이 필요한 테스트에서만 사용 → import로 의도 표현
  • ESLint no-undef 규칙과 IDE 자동완성 지원에도 유리

이 한 줄이 해주는 일:

// 이런 편리한 매처들을 사용할 수 있게 됨 expect(element).toBeInTheDocument(); // DOM에 존재하는지 expect(element).toBeVisible(); // 보이는지 expect(element).toBeDisabled(); // 비활성화됐는지 expect(element).toHaveTextContent("텍스트"); // 텍스트 포함하는지 expect(element).toHaveClass("클래스명"); // 클래스 있는지 expect(element).toHaveAttribute("속성", ""); // 속성값 확인

6.3 tsconfig.json 수정

TypeScript가 테스트 타입을 인식하도록 설정합니다:

{ "compilerOptions": { "types": ["vitest/globals", "@testing-library/jest-dom"] } }

⚠️ @testing-library/jest-dom 타입이 없으면?

  • toBeInTheDocument(), toHaveTextContent() 등에서 IDE 빨간줄 또는 tsc 에러 발생
  • 실행은 되는데 타입 체크만 실패하는 경우가 많음
  • 해결: 위처럼 types 배열에 @testing-library/jest-dom 추가

🚨 중요: types 배열은 기존 타입을 덮어씁니다!

이 설정 때문에 앱 코드가 깨지는 경우가 매우 많습니다:

// ❌ types를 설정하면 기존에 암묵적으로 포함되던 타입이 사라짐 import.meta.env.VITE_API_URL; // Error: 'env' does not exist on type 'ImportMeta' process.env.NODE_ENV; // Error: Cannot find name 'process'

해결 방법 1: 필요한 타입 모두 명시

"types": ["vitest/globals", "@testing-library/jest-dom", "vite/client", "node"]

해결 방법 2 (권장): tsconfig 분리 — 아래 참고

✅ 권장: 테스트 전용 tsconfig 분리

types 배열 충돌 문제를 근본적으로 해결하려면 테스트 전용 설정 파일을 분리하는 것이 좋습니다:

// tsconfig.vitest.json { "extends": "./tsconfig.json", "compilerOptions": { "types": ["vitest/globals", "@testing-library/jest-dom"] }, "include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/setupTests.ts"] }
// vitest.config.ts export default defineConfig({ test: { typecheck: { tsconfig: "./tsconfig.vitest.json", }, // ... 기존 설정 }, });

이렇게 하면:

  • 앱 코드는 tsconfig.json의 types 사용 (vite/client 등)
  • 테스트 코드는 tsconfig.vitest.json의 types 사용 (vitest/globals 등)
  • 타입 충돌 없이 깔끔하게 분리됨

6.4 package.json 스크립트 추가

{ "scripts": { "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest --coverage", "test:run": "vitest run" } }

각 스크립트 설명:

| 명령어 | 설명 | | -------------------- | ------------------------------------------------------ | | pnpm test | watch 모드로 실행. 파일 저장하면 자동 재실행 | | pnpm test:ui | 브라우저에서 예쁜 UI로 테스트 결과 확인 | | pnpm test:coverage | 코드 커버리지 리포트 생성 (@vitest/coverage-v8 필요) | | pnpm test:run | 한 번만 실행 (CI/CD용) |


7. 첫 테스트 실행해보기

7.1 테스트 파일 만들기

src/example.test.ts 파일을 만듭니다:

// 가장 간단한 테스트 describe("첫 번째 테스트", () => { it("1 + 1은 2이다", () => { expect(1 + 1).toBe(2); }); });

7.2 테스트 실행

터미널에서:

pnpm test

결과:

 ✓ src/example.test.ts (1)
   ✓ 첫 번째 테스트 (1)
     ✓ 1 + 1은 2이다

 Test Files  1 passed (1)
      Tests  1 passed (1)

🎉 축하합니다! 첫 테스트를 통과했습니다!

7.3 일부러 실패시켜보기

describe("첫 번째 테스트", () => { it("1 + 1은 2이다", () => { expect(1 + 1).toBe(3); // 일부러 틀린 값 }); });

결과:

 ❌ src/example.test.ts (1)
   ❌ 첫 번째 테스트 (1)
     ❌ 1 + 1은 2이다

   AssertionError: expected 2 to be 3

   - Expected: 3
   + Received: 2

에러 메시지가 **기대한 값(Expected)**과 **실제 값(Received)**을 알려줍니다!

7.4 UI 모드로 보기

pnpm test:ui

브라우저가 열리면서 테스트 결과를 시각적으로 확인할 수 있습니다.


Part 3: 테스트 문법 배우기

8. describe, it, expect 이해하기

8.1 describe: 테스트 그룹

describe("계산기", () => { // 계산기 관련 테스트들이 여기에 }); describe("로그인", () => { // 로그인 관련 테스트들이 여기에 });

중첩도 가능:

describe("계산기", () => { describe("덧셈", () => { // 덧셈 테스트들 }); describe("뺄셈", () => { // 뺄셈 테스트들 }); });

8.2 it (또는 test): 개별 테스트

describe("계산기", () => { it("1 + 1은 2이다", () => { // 테스트 내용 }); it("2 - 1은 1이다", () => { // 테스트 내용 }); // test와 it은 같은 것! test("3 * 3은 9이다", () => { // 테스트 내용 }); });

테스트 설명은 한글로 쓰는 것을 추천:

// ❌ 영어로 쓰면 읽기 힘듦 it("should return 2 when adding 1 and 1", () => {}); // ✅ 한글로 쓰면 바로 이해됨 it("1과 1을 더하면 2를 반환한다", () => {});

테스트 네이밍 규칙 - 문서화를 위한 템플릿:

| 패턴 | 예시 | | ---------------------------- | -------------------------------------------- | | ~하면 ~된다 | 버튼을 클릭하면 카운트가 증가한다 | | ~일 때 ~가 보인다/사라진다 | 로딩 중일 때 스피너가 보인다 | | ~이면 ~가 비활성화된다 | 금액이 0이면 확인 버튼이 비활성화된다 | | ~를 입력하면 ~가 표시된다 | 이메일을 입력하면 유효성 메시지가 표시된다 |

describe("송금 화면", () => { it("금액을 입력하면 천단위 콤마가 표시된다", () => {}); it("1회 한도를 초과하면 에러 메시지가 표시된다", () => {}); it("확인 버튼을 클릭하면 송금이 실행된다", () => {}); it("송금 중일 때 로딩 화면이 보인다", () => {}); it("송금 성공 시 완료 화면으로 이동한다", () => {}); });

: 테스트 설명은 "문서"입니다. 테스트 실패 시 어떤 기능이 깨졌는지 바로 알 수 있게 작성하세요.

8.3 expect: 기대값 검증

expect(실제값).매처(기대값);
// 예시 expect(1 + 1).toBe(2); // 1+1이 2인지? expect([1, 2]).toContain(1); // 배열에 1이 있는지? expect(null).toBeNull(); // null인지?

8.4 테스트의 3단계: AAA 패턴

모든 테스트는 세 단계로 구성됩니다:

it('버튼을 클릭하면 카운트가 1 증가한다', async () => { // 1. Arrange (준비) render(<Counter />); const button = screen.getByRole('button'); // 2. Act (실행) await user.click(button); // 3. Assert (검증) expect(screen.getByText('1')).toBeInTheDocument(); });

AAA 패턴:

  • Arrange: 테스트에 필요한 것들 준비
  • Act: 테스트하려는 동작 실행
  • Assert: 결과가 예상과 같은지 확인

9. 자주 쓰는 매처(Matcher)

9.1 기본 매처

// 정확히 같은지 (===) expect(2 + 2).toBe(4); expect("hello").toBe("hello"); // 객체/배열 내용이 같은지 (깊은 비교) expect({ a: 1 }).toEqual({ a: 1 }); // ✅ 통과 expect({ a: 1 }).toBe({ a: 1 }); // ❌ 실패 (다른 객체) // 참/거짓 expect(true).toBeTruthy(); expect(false).toBeFalsy(); expect(0).toBeFalsy(); // 0은 falsy expect("").toBeFalsy(); // 빈 문자열은 falsy expect([]).toBeTruthy(); // 빈 배열은 truthy! // null, undefined expect(null).toBeNull(); expect(undefined).toBeUndefined(); expect().toBeDefined();

9.2 숫자 매처

expect(10).toBeGreaterThan(5); // 10 > 5 expect(10).toBeGreaterThanOrEqual(10); // 10 >= 10 expect(5).toBeLessThan(10); // 5 < 10 expect(5).toBeLessThanOrEqual(5); // 5 <= 5 // 소수점 비교 (부동소수점 문제 해결) expect(0.1 + 0.2).toBeCloseTo(0.3); // ✅ expect(0.1 + 0.2).toBe(0.3); // ❌ 실패!

9.3 문자열 매처

expect("hello world").toContain("world"); // 포함하는지 expect("hello world").toMatch(/world/); // 정규식 매치 expect("HELLO").toMatch(/hello/i); // 대소문자 무시

9.4 배열 매처

const fruits = ["apple", "banana", "cherry"]; expect(fruits).toContain("banana"); // 요소 포함 expect(fruits).toHaveLength(3); // 길이 expect(fruits).toEqual(["apple", "banana", "cherry"]); // 내용 일치

9.5 객체 매처

const user = { name: "Kim", age: 25, email: "kim@test.com" }; // 특정 속성만 확인 expect(user).toHaveProperty("name"); expect(user).toHaveProperty("name", "Kim"); // 일부 속성만 매칭 expect(user).toMatchObject({ name: "Kim", age: 25 }); // email이 없어도 통과!

9.6 DOM 매처 (@testing-library/jest-dom)

const button = screen.getByRole("button"); expect(button).toBeInTheDocument(); // DOM에 존재하는지 expect(button).toBeVisible(); // 보이는지 expect(button).toBeEnabled(); // 활성화됐는지 expect(button).toBeDisabled(); // 비활성화됐는지 expect(button).toHaveFocus(); // 포커스됐는지 expect(button).toHaveTextContent("클릭"); // 텍스트 포함 expect(button).toHaveClass("primary"); // 클래스 있는지 expect(button).toHaveAttribute("type", "submit"); // 속성값 expect(button).toHaveStyle({ color: "red" }); // 스타일

9.7 부정 매처 (not)

expect(1).not.toBe(2); expect([1, 2]).not.toContain(3); expect(button).not.toBeDisabled();

9.8 함수 호출 매처

const mockFn = vi.fn(); mockFn("hello"); mockFn("world"); expect(mockFn).toHaveBeenCalled(); // 호출됐는지 expect(mockFn).toHaveBeenCalledTimes(2); // 2번 호출됐는지 expect(mockFn).toHaveBeenCalledWith("hello"); // 'hello'로 호출됐는지 expect(mockFn).toHaveBeenLastCalledWith("world"); // 마지막 호출 인자

10. 테스트 전후 처리: beforeEach, afterEach

10.1 왜 필요한가?

describe('Counter', () => { it('증가 버튼 클릭 시 1 증가', () => { render(<Counter />); // ... }); it('감소 버튼 클릭 시 1 감소', () => { render(<Counter />); // 또 render? // ... }); it('초기값은 0', () => { render(<Counter />); // 또 render? // ... }); });

매번 같은 코드를 반복하게 됩니다. 이럴 때 beforeEach를 사용합니다.

10.2 beforeEach: 각 테스트 전에 실행

describe('Counter', () => { beforeEach(() => { render(<Counter />); // 각 it 전에 자동 실행 }); it('증가 버튼 클릭 시 1 증가', () => { // render 이미 됨! }); it('감소 버튼 클릭 시 1 감소', () => { // render 이미 됨! }); });

10.3 afterEach: 각 테스트 후에 실행

describe("API 테스트", () => { beforeEach(() => { setupMocks(); // 각 테스트 전에 mock 설정 }); afterEach(() => { vi.clearAllMocks(); // 각 테스트 후에 mock 정리 }); });

RTL cleanup 참고: 대부분의 경우 RTL이 자동 cleanup을 해주므로 직접 호출할 필요가 없습니다.

수동 afterEach(cleanup)이 필요한 경우는 매우 드뭅니다 — 커스텀 test renderer를 사용하거나, 특수한 테스트 환경을 직접 구성한 경우에만 해당됩니다. 혹시 "이전 테스트의 DOM이 남아있다"는 문제가 생기면 그때 추가해도 늦지 않습니다.

10.4 beforeAll, afterAll: 전체 테스트 전후

describe("데이터베이스 테스트", () => { beforeAll(() => { // 모든 테스트 시작 전에 한 번만 실행 connectDatabase(); }); afterAll(() => { // 모든 테스트 끝난 후에 한 번만 실행 disconnectDatabase(); }); });

10.5 실행 순서 이해하기

describe("테스트 순서", () => { beforeAll(() => console.log("1. beforeAll")); beforeEach(() => console.log("2. beforeEach")); afterEach(() => console.log("4. afterEach")); afterAll(() => console.log("6. afterAll")); it("첫 번째 테스트", () => { console.log("3. 첫 번째 테스트 실행"); }); it("두 번째 테스트", () => { console.log("5. 두 번째 테스트 실행"); }); });

출력:

1. beforeAll
2. beforeEach
3. 첫 번째 테스트 실행
4. afterEach
2. beforeEach
5. 두 번째 테스트 실행
4. afterEach
6. afterAll

Part 4: React 컴포넌트 테스트하기

11. render와 screen 이해하기

11.1 render: 컴포넌트를 가상 DOM에 그리기

import { render } from '@testing-library/react'; function Button({ label }: { label: string }) { return <button>{label}</button>; } it('버튼이 렌더링된다', () => { render(<Button label="클릭" />); // 이제 가상 DOM에 <button>클릭</button>이 있음 });

render의 반환값:

const { container, rerender, unmount } = render(<Button label="클릭" />); // container: 렌더링된 DOM 요소 console.log(container.innerHTML); // <button>클릭</button> // rerender: 같은 컴포넌트를 다른 props로 다시 렌더링 rerender(<Button label="다시 클릭" />); // unmount: 컴포넌트 제거 unmount();

11.2 screen: 렌더링된 요소 찾기

import { render, screen } from '@testing-library/react'; it('버튼이 렌더링된다', () => { render(<Button label="클릭" />); // screen을 통해 요소를 찾음 const button = screen.getByRole('button'); expect(button).toBeInTheDocument(); });

왜 screen을 사용하나요?

// ❌ 구버전 스타일 (권장하지 않음) const { getByRole } = render(<Button />); const button = getByRole('button'); // ✅ 현재 권장 스타일 render(<Button />); const button = screen.getByRole('button');
  • screen은 항상 최신 DOM 상태를 반영
  • 여러 render 호출해도 screen 하나로 접근 가능
  • 코드 가독성이 더 좋음

11.3 debug: 현재 DOM 상태 보기

테스트가 실패할 때 가장 유용한 도구입니다!

it('버튼이 렌더링된다', () => { render(<Button label="클릭" />); screen.debug(); // 현재 DOM 출력 });

출력:

<body> <div> <button>클릭</button> </div> </body>

특정 요소만 보기:

screen.debug(screen.getByRole("button"));

11.4 디버깅 3종 도구

테스트 실패 시 원인을 빠르게 파악하는 도구들:

import { logRoles } from '@testing-library/dom'; import { prettyDOM } from '@testing-library/react'; // 1. screen.debug() - 전체 DOM 출력 screen.debug(); // 2. logRoles() - 접근성 role 확인 (쿼리가 안 될 때 유용) const { container } = render(<MyComponent />); logRoles(container); // 출력 예시: // button: [<button>확인</button>] // heading: [<h1>제목</h1>] // textbox: [<input type="text">] // 3. prettyDOM() - 특정 요소만 예쁘게 출력 console.log(prettyDOM(screen.getByRole('form')));

언제 어떤 도구를 사용할까?

| 도구 | 사용 상황 | | --------------------- | ---------------------------------------------- | | screen.debug() | 전체 DOM 구조 확인 | | logRoles(container) | getByRole이 안 될 때 어떤 role이 있는지 확인 | | prettyDOM(element) | 특정 요소만 깔끔하게 출력 |


12. 요소 찾기: 쿼리 메서드

12.1 쿼리 종류

| 종류 | 요소 없을 때 | 비동기 | 사용 시점 | | --------- | ------------ | ------ | ------------------------ | | getBy | 에러 발생 | X | 요소가 반드시 있을 때 | | queryBy | null 반환 | X | 요소가 없을 수도 있을 때 | | findBy | 에러 발생 | O | 요소가 나중에 나타날 때 |

복수형도 있습니다:

  • getAllBy - 여러 요소 찾기 (없으면 에러)
  • queryAllBy - 여러 요소 찾기 (없으면 빈 배열)
  • findAllBy - 여러 요소 비동기로 찾기

12.2 언제 어떤 쿼리를 사용할까?

// 1. getBy: 요소가 반드시 있어야 할 때 const button = screen.getByRole("button"); // 2. queryBy: 요소가 없는 것을 테스트할 때 expect(screen.queryByText("에러 메시지")).not.toBeInTheDocument(); // (getByText를 쓰면 에러 나서 테스트 실패) // 3. findBy: 비동기로 나타나는 요소 const message = await screen.findByText("로딩 완료");

12.3 쿼리 우선순위 규칙

팀에서 규칙으로 정해두면 일관된 테스트를 작성할 수 있습니다:

| 우선순위 | 쿼리 | 사용 상황 | | ----------- | ---------------------- | ---------------------------------------- | | 1순위 | getByRole(name) | 버튼, 링크, 입력 필드 등 대부분의 요소 | | 2순위 | getByLabelText | 폼 입력 필드 (가장 권장) | | 3순위 | getByText | 정적 텍스트 | | 4순위 | getByAltText | 이미지 | | ⚠️ 지양 | getByPlaceholderText | label을 추가할 수 없는 레거시 코드에서만 | | 최후 | getByTestId | 다른 방법이 없을 때만 (사유 주석 필수) |

⚠️ placeholder는 label의 대체재가 아닙니다!

  • placeholder는 입력 시작 시 사라져 맥락을 잃음
  • 스크린 리더 지원이 불안정함
  • 권장: 테스트가 getByPlaceholderText를 쓰게 되면, 그건 "label을 추가하라"는 신호입니다.
// ✅ 1순위: role + name (가장 추천) screen.getByRole("button", { name: "확인" }); screen.getByRole("heading", { name: "제목" }); screen.getByRole("textbox", { name: "이메일" }); // ✅ 2순위: label (폼 요소 - 가장 권장) screen.getByLabelText("이메일"); screen.getByLabelText("비밀번호"); // ⚠️ 지양: placeholder (레거시 코드에서만 어쩔 수 없이) screen.getByPlaceholderText("이메일을 입력하세요"); // label 추가를 고려하세요! // 4순위: text (정적 텍스트) screen.getByText("안녕하세요"); // 5순위: alt (이미지) screen.getByAltText("프로필 사진"); // ❌ 최후의 수단: testId (사유 필수) // DOM 구조상 다른 쿼리가 불가능할 때만 사용 screen.getByTestId("complex-chart-container"); // 차트 라이브러리 컨테이너

규칙: testId를 사용할 때는 왜 다른 쿼리를 사용할 수 없는지 주석으로 남기세요.

12.4 role이란?

HTML 요소는 암묵적인 role(역할)을 가집니다:

| HTML | Role | | ------------------------- | -------- | | <button> | button | | <a href="..."> | link | | <input type="text"> | textbox | | <input type="checkbox"> | checkbox | | <select> | combobox | | <ul>, <ol> | list | | <li> | listitem | | <h1>~<h6> | heading | | <img> | img | | <table> | table |

// HTML <button>확인</button> <a href="/home">홈으로</a> <input type="text" placeholder="이름" /> // 테스트 screen.getByRole('button', { name: '확인' }); screen.getByRole('link', { name: '홈으로' }); screen.getByRole('textbox', { name: '이름' }); // placeholder가 name으로 사용될 수 있음

주의: placeholder는 경우에 따라 accessible name으로 사용될 수 있지만, 환경이나 마크업 조합에 따라 다르게 동작할 수 있습니다. 접근성 관점에서도 placeholder만으로는 부족합니다:

  • placeholder는 입력 시작 시 사라져 맥락을 잃음
  • 시각적 대비가 낮아 가독성 문제
  • 권장: <label>을 함께 사용하고, getByLabelText로 테스트하세요

12.5 name 옵션

name은 스크린 리더가 읽는 텍스트입니다:

// 버튼 텍스트 <button>확인</button> screen.getByRole('button', { name: '확인' }); // aria-label <button aria-label="닫기">X</button> screen.getByRole('button', { name: '닫기' }); // label 연결 <label htmlFor="email">이메일</label> <input id="email" type="text" /> screen.getByRole('textbox', { name: '이메일' });

12.6 실전 예시

// 컴포넌트 - label과 input을 htmlFor/id로 연결하는 패턴 (권장) function LoginForm() { return ( <form> <h1>로그인</h1> <label htmlFor="email">이메일</label> <input id="email" type="email" placeholder="example@email.com" /> <label htmlFor="password">비밀번호</label> <input id="password" type="password" /> <button type="submit">로그인</button> <a href="/signup">회원가입</a> </form> ); }
// 테스트 it('로그인 폼이 올바르게 렌더링된다', () => { render(<LoginForm />); // 제목 expect(screen.getByRole('heading', { name: '로그인' })).toBeInTheDocument(); // 입력 필드 - getByLabelText가 가장 권장되는 방식 expect(screen.getByLabelText('이메일')).toBeInTheDocument(); expect(screen.getByLabelText('비밀번호')).toBeInTheDocument(); // 버튼 expect(screen.getByRole('button', { name: '로그인' })).toBeInTheDocument(); // 링크 expect(screen.getByRole('link', { name: '회원가입' })).toBeInTheDocument(); });

: <input type="password">도 접근성 트리에서는 보통 role="textbox"로 인식됩니다. 다만 폼 테스트에서는 role보다 label 연결이 더 의미 있고 안정적이라, 비밀번호 필드는 getByLabelText('비밀번호')를 1순위로 권장합니다.

expect(screen.getByRole("textbox", { name: "비밀번호" })).toBeInTheDocument(); // 가능 expect(screen.getByLabelText("비밀번호")).toBeInTheDocument(); // 더 권장

13. 사용자 행동 시뮬레이션

13.1 userEvent 설정

import userEvent from '@testing-library/user-event'; it('버튼을 클릭할 수 있다', async () => { const user = userEvent.setup(); // 항상 setup 먼저! render(<Button />); await user.click(screen.getByRole('button')); });

왜 setup()이 필요한가요?

  • 실제 브라우저처럼 이벤트 순서 보장 (mousedown → mouseup → click)
  • 타이핑 시 딜레이 시뮬레이션
  • 더 현실적인 테스트

⚠️ 필수 규칙: userEvent는 항상 await 사용

userEvent의 모든 메서드는 async입니다. await 없이 사용하면 flaky 테스트의 원인이 됩니다.

// ❌ 나쁜 예: await 없음 → flaky 테스트 발생 user.click(button); expect(result).toBeInTheDocument(); // 클릭 완료 전에 검증할 수 있음! // ✅ 좋은 예: await 필수 await user.click(button); expect(result).toBeInTheDocument();

이 규칙을 어기면 "로컬에서는 통과, CI에서는 실패"하는 상황이 발생합니다.

규칙: userEvent vs fireEvent

| 구분 | userEvent | fireEvent | | ----------------- | ------------ | --------- | | 기본 | ✅ 항상 사용 | ❌ 지양 | | 드래그/스크롤 | 제한적 | ✅ 사용 | | 특수 이벤트 | 제한적 | ✅ 사용 |

기본은 userEvent를 사용합니다. fireEvent는 드래그/스크롤/특수 이벤트 등 userEvent로 표현하기 어려운 경우에만 제한적으로 사용합니다.

13.2 클릭

const user = userEvent.setup(); // 단일 클릭 await user.click(element); // 더블 클릭 await user.dblClick(element); // 우클릭 await user.pointer({ target: element, keys: "[MouseRight]" });

13.3 타이핑

const user = userEvent.setup(); const input = screen.getByRole("textbox"); // 텍스트 입력 await user.type(input, "hello"); expect(input).toHaveValue("hello"); // 특수 키 await user.type(input, "{Enter}"); // Enter await user.type(input, "{Backspace}"); // 백스페이스 await user.type(input, "{Tab}"); // Tab // 기존 텍스트 지우고 입력 await user.clear(input); await user.type(input, "new text");

13.4 키보드

const user = userEvent.setup(); // Tab으로 포커스 이동 await user.tab(); // 특정 키 누르기 await user.keyboard("{Enter}"); await user.keyboard("{Escape}"); // 조합키 await user.keyboard("{Control>}a{/Control}"); // Ctrl+A (전체 선택)

13.5 실전 예시: 폼 테스트

import { useState } from "react"; function LoginForm({ onSubmit }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const handleSubmit = (e) => { e.preventDefault(); onSubmit({ email, password }); }; return ( <form onSubmit={handleSubmit}> <label htmlFor="email">이메일</label> <input id="email" type="email" value={email} onChange={(e)=> setEmail(e.target.value)} /> <label htmlFor="password">비밀번호</label> <input id="password" type="password" value={password} onChange={(e)=> setPassword(e.target.value)} /> <button type="submit">로그인</button> </form> ); }
describe('LoginForm', () => { it('이메일과 비밀번호를 입력하고 제출할 수 있다', async () => { const user = userEvent.setup(); const handleSubmit = vi.fn(); // 가짜 함수 render(<LoginForm onSubmit={handleSubmit} />); // 이메일 입력 - getByLabelText 사용 await user.type(screen.getByLabelText('이메일'), 'test@example.com'); // 비밀번호 입력 - getByLabelText 사용 await user.type(screen.getByLabelText('비밀번호'), 'password123'); // 제출 await user.click(screen.getByRole('button', { name: '로그인' })); // 검증 expect(handleSubmit).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123', }); }); });

14. 비동기 테스트: 기다리기

14.1 비동기가 필요한 상황

import { useState, useEffect } from "react"; function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { fetchUser(userId).then((data) => { setUser(data); setLoading(false); }); }, [userId]); if (loading) return <p>로딩 중...</p>; return <p>{user.name}</p>; }

이 컴포넌트를 테스트하면:

it('사용자 이름이 표시된다', () => { render(<UserProfile userId="1" />); // ❌ 실패! API 응답 오기 전에 검증함 expect(screen.getByText('김철수')).toBeInTheDocument(); });

14.2 findBy: 비동기로 요소 찾기

it('사용자 이름이 표시된다', async () => { render(<UserProfile userId="1" />); // ✅ 요소가 나타날 때까지 기다림 (기본 1초) expect(await screen.findByText('김철수')).toBeInTheDocument(); });

findBy는 내부적으로 반복해서 요소를 찾습니다:

0ms: '김철수' 없음... 다시 시도
50ms: '김철수' 없음... 다시 시도
100ms: '김철수' 없음... 다시 시도
150ms: '김철수' 찾음! ✅

14.3 waitFor: 조건이 만족될 때까지 기다리기

import { waitFor } from '@testing-library/react'; // ❌ 피해야 할 패턴: 단순 요소 출현에 waitFor 사용 // → 단일 요소를 기다릴 때는 findBy가 코드가 짧고 의도가 명확합니다 it('사용자 이름이 표시된다', async () => { render(<UserProfile userId="1" />); await waitFor(() => { expect(screen.getByText('김철수')).toBeInTheDocument(); }); }); // ✅ 권장 패턴: findBy 사용 it('사용자 이름이 표시된다', async () => { render(<UserProfile userId="1" />); expect(await screen.findByText('김철수')).toBeInTheDocument(); });

findBy vs waitFor - 언제 무엇을 사용할까?

// ✅ findBy: 단순히 요소가 나타나기를 기다릴 때 (권장) // - 비동기로 나타나는 단일 요소를 찾을 때 // - 코드가 더 간결하고 의도가 명확함 const element = await screen.findByText("김철수"); expect(element).toBeInTheDocument(); // ✅ waitFor: 복잡한 조건을 기다릴 때만 사용 // - 여러 조건을 동시에 확인할 때 // - 요소가 "사라지는 것"을 확인할 때 // - 함수 호출 여부 등 DOM 외 조건을 확인할 때 await waitFor(() => { expect(screen.queryByText("로딩 중")).not.toBeInTheDocument(); // 사라짐 확인 expect(screen.getByText("김철수")).toBeInTheDocument(); expect(mockFn).toHaveBeenCalled(); // 함수 호출 확인 });

핵심: 요소가 나타나기를 기다릴 때는 findBy, 복잡한 조건이나 요소 사라짐을 확인할 때는 waitFor를 사용하세요.

비동기 테스트 3종 세트:

| 함수 | 사용 상황 | 예시 | | --------------------------- | ------------------------------- | ----------------------------------------------------------------- | | findBy* | 요소가 나타나기를 기다릴 때 | await screen.findByText('완료') | | waitFor | 복잡한 조건/함수 호출 확인 | await waitFor(() => expect(fn).toHaveBeenCalled()) | | waitForElementToBeRemoved | 요소가 사라지기를 기다릴 때 | await waitForElementToBeRemoved(() => screen.getByText('로딩')) |

import { waitForElementToBeRemoved } from "@testing-library/react"; // 로딩이 사라질 때까지 기다리는 가장 명확한 방법 await waitForElementToBeRemoved(() => screen.getByText("로딩 중..."));

14.4 타임아웃 설정

1. Vitest 전역 타임아웃 설정

// vitest.config.ts export default defineConfig({ test: { testTimeout: 5000, // 개별 테스트 타임아웃 (기본 5초) hookTimeout: 5000, // beforeEach/afterEach 타임아웃 }, });

2. RTL findBy/waitFor 타임아웃 설정

// findBy 타임아웃 (기본 1초) await screen.findByText("완료", {}, { timeout: 3000 }); // waitFor 타임아웃 (기본 1초) await waitFor( () => { expect(screen.getByText("완료")).toBeInTheDocument(); }, { timeout: 5000 }, );

참고: RTL의 findBy/waitFor 기본 타임아웃(1초)과 Vitest의 테스트 타임아웃은 별개입니다. RTL 타임아웃이 먼저 발생하면 테스트가 실패합니다.

14.5 로딩 상태 테스트

describe('UserProfile', () => { it('로딩 중일 때 로딩 메시지가 표시된다', () => { render(<UserProfile userId="1" />); expect(screen.getByText('로딩 중...')).toBeInTheDocument(); }); it('로딩 완료 후 사용자 이름이 표시된다', async () => { render(<UserProfile userId="1" />); // ✅ findBy로 간단히 기다리기 - 로딩 완료되면 이름이 나타남 expect(await screen.findByText('김철수')).toBeInTheDocument(); }); it('로딩 완료 후 로딩 메시지가 사라진다', async () => { render(<UserProfile userId="1" />); // 처음에는 로딩 메시지가 있음 expect(screen.getByText('로딩 중...')).toBeInTheDocument(); // ✅ waitFor는 "사라짐"을 확인할 때 사용 await waitFor(() => { expect(screen.queryByText('로딩 중...')).not.toBeInTheDocument(); }); }); });

14.6 타이머 제어하기

setTimeout, setInterval을 사용하는 코드:

import { useEffect } from "react"; function Toast({ message, onClose }) { useEffect(() => { const timer = setTimeout(onClose, 3000); // 3초 후 닫힘 return () => clearTimeout(timer); }, [onClose]); return <div role="alert">{message}</div>; }
describe('Toast', () => { beforeEach(() => { vi.useFakeTimers(); // 가짜 타이머 사용 }); afterEach(() => { vi.useRealTimers(); // 실제 타이머로 복원 }); it('3초 후에 onClose가 호출된다', () => { const onClose = vi.fn(); render(<Toast message="알림" onClose={onClose} />); // 아직 호출 안 됨 expect(onClose).not.toHaveBeenCalled(); // 3초 경과 act(() => { vi.advanceTimersByTime(3000); }); // 이제 호출됨 expect(onClose).toHaveBeenCalled(); }); });

가짜 타이머가 필요한 이유:

  • 실제로 3초를 기다리면 테스트가 느려짐
  • vi.advanceTimersByTime(3000)으로 시간을 "빨리 감기"

⚠️ 주의: vi.useFakeTimers()userEvent를 함께 사용할 때 주의하세요. userEvent 내부에서 타이머를 사용하기 때문에 충돌이 발생할 수 있습니다.

권장 패턴:

  • 타이머를 쓴 컴포넌트 테스트에서만 fake timers를 켜기
  • 테스트 끝나면 반드시 vi.useRealTimers()로 복구
  • userEvent와 함께 쓸 때는 userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) 옵션 고려
import { act } from '@testing-library/react'; // ✅ fake timers 베스트 프랙티스 describe('ToastMessage', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); // 반드시 복구! }); it('3초 후 토스트가 사라진다', async () => { render(<ToastMessage message="저장됨" />); expect(screen.getByText('저장됨')).toBeInTheDocument(); act(() => { vi.advanceTimersByTime(3000); }); expect(screen.queryByText('저장됨')).not.toBeInTheDocument(); }); });

Part 5: 고급 테스트 기법

15. Mock이란 무엇인가

15.1 Mock의 개념

Mock = 가짜

테스트할 때 실제 대신 가짜를 사용하는 것입니다.

실제 상황:
컴포넌트 → API 서버 → 데이터베이스
           (느림, 불안정, 돈 듦)

테스트 상황:
컴포넌트 → Mock API (가짜)
           (빠름, 안정적, 무료)

15.2 왜 Mock이 필요한가?

// ❌ 실제 API를 호출하면 it('사용자 목록을 가져온다', async () => { render(<UserList />); // 문제점: // 1. 서버가 꺼져있으면? → 테스트 실패 // 2. 네트워크가 느리면? → 테스트 느림 // 3. 데이터가 바뀌면? → 테스트 결과 달라짐 // 4. 100번 테스트하면? → API 비용 발생 }); // ✅ Mock을 사용하면 (개념 예시 - 실제 문법은 아래 섹션 참고) it('사용자 목록을 가져온다', async () => { // 가짜 응답 설정 (실제로는 vi.mock 또는 MSW 사용) mockApi.get('/users').reply([{ id: 1, name: '김철수' }]); render(<UserList />); // 장점: // 1. 서버 상태와 무관하게 테스트 가능 // 2. 즉시 응답하므로 빠름 // 3. 항상 같은 데이터로 일관된 테스트 // 4. 비용 없음 });

참고: 위 mockApi.get(...).reply(...)는 개념 설명용 의사코드입니다. 실제 구현은 아래 "16. API 호출 Mocking" 섹션의 vi.mock 패턴을 참고하세요.

15.3 vi.fn(): 가짜 함수 만들기

// 가짜 함수 생성 const mockFn = vi.fn(); // 호출 mockFn("hello"); mockFn("world"); // 검증 expect(mockFn).toHaveBeenCalled(); // 호출됐는지 expect(mockFn).toHaveBeenCalledTimes(2); // 2번 호출됐는지 expect(mockFn).toHaveBeenCalledWith("hello"); // 'hello'로 호출됐는지

언제 사용?

// onClick 핸들러 테스트 it('버튼 클릭 시 onClick이 호출된다', async () => { const handleClick = vi.fn(); // 가짜 함수 const user = userEvent.setup(); render(<Button onClick={handleClick}>클릭</Button>); await user.click(screen.getByRole('button')); expect(handleClick).toHaveBeenCalledTimes(1); });

15.4 vi.fn()에 반환값 설정하기

const mockGetUser = vi.fn(); // 항상 같은 값 반환 mockGetUser.mockReturnValue({ name: "김철수" }); console.log(mockGetUser()); // { name: '김철수' } // Promise 반환 (async 함수용) mockGetUser.mockResolvedValue({ name: "김철수" }); const user = await mockGetUser(); // { name: '김철수' } // 에러 발생 mockGetUser.mockRejectedValue(new Error("실패")); await mockGetUser(); // Error: 실패

15.5 vi.mock(): 모듈 전체 Mock

⚠️ vi.mock()과 hoisting 이해하기

vi.mock()은 Vitest가 자동으로 파일 최상단으로 끌어올립니다(hoisting). 그래서 import 아래에 써도 실행 자체는 됩니다.

진짜 문제는 factory 함수에서 외부 변수를 참조할 때:

const mockData = { name: "김철수" }; // ❌ hoisting 시점에 아직 없음! vi.mock("@/api/user", () => ({ fetchUser: vi.fn().mockResolvedValue(mockData), // 에러 발생 }));

권장 패턴: factory는 vi.fn()만 반환하고, 구체적인 값은 beforeEach에서 설정

// ✅ 파일 상단: factory는 vi.fn()만 반환 vi.mock("@/api/user", () => ({ fetchUser: vi.fn(), })); import { fetchUser } from "@/api/user"; describe("UserProfile", () => { // ✅ beforeEach: 테스트별 Mock 값 설정 beforeEach(() => { vi.mocked(fetchUser).mockResolvedValue({ id: 1, name: "김철수" }); }); it("사용자 이름을 표시한다", async () => { // fetchUser가 호출되면 { id: 1, name: '김철수' } 반환 }); });
// 실제 모듈 // src/api/user.ts export async function fetchUser(id) { const response = await fetch(`/api/users/${id}`); return response.json(); } // ✅ 테스트 파일 최상단에 mock 선언 vi.mock('@/api/user', () => ({ fetchUser: vi.fn(), })); import { fetchUser } from '@/api/user'; it('사용자를 가져온다', async () => { // Mock 함수에 반환값 설정 vi.mocked(fetchUser).mockResolvedValue({ id: 1, name: '김철수' }); // 컴포넌트가 fetchUser를 호출하면 Mock 값이 반환됨 render(<UserProfile userId="1" />); await screen.findByText('김철수'); });

16. API 호출 Mocking

16.1 API 클라이언트 Mock 기본 패턴

// src/shared/api/client.ts (실제 코드) export async function apiClient(endpoint: string, options?: RequestInit) { const response = await fetch(`http://localhost:3001${endpoint}`, options); return response.json(); }
// 테스트 파일 vi.mock('@/shared/api/client', () => ({ apiClient: vi.fn(), })); import { apiClient } from '@/shared/api/client'; describe('AccountList', () => { beforeEach(() => { // 각 테스트 전에 Mock 응답 설정 vi.mocked(apiClient).mockResolvedValue([ { id: 1, name: '신한은행', balance: 1000000 }, { id: 2, name: '국민은행', balance: 2000000 }, ]); }); afterEach(() => { vi.clearAllMocks(); // Mock 호출 기록 초기화 }); it('계좌 목록이 렌더링된다', async () => { render(<AccountList />); await screen.findByText('신한은행'); expect(screen.getByText('국민은행')).toBeInTheDocument(); }); });

16.2 여러 엔드포인트 Mock하기

vi.mock('@/shared/api/client', () => ({ apiClient: vi.fn(), })); function setupMocks() { vi.mocked(apiClient).mockImplementation((endpoint: string) => { // 엔드포인트별로 다른 응답 반환 switch (endpoint) { case '/my_accounts': return Promise.resolve([ { id: 1, holder_name: '김준태', bank: { name: '신한' } }, ]); case '/recents_transfer_accounts': return Promise.resolve([ { id: 1, holder_name: '김하진', bank: { name: '국민' } }, ]); case '/bookmark_accounts': return Promise.resolve([ { id: 1, bank_account_number: '110-1234-5678' }, ]); default: return Promise.reject(new Error(`Unknown endpoint: ${endpoint}`)); } }); } describe('SelectAccountPage', () => { beforeEach(() => { setupMocks(); }); it('내 계좌와 최근 송금 계좌가 모두 표시된다', async () => { render(<SelectAccountPage />); await screen.findByText('김준태'); expect(screen.getByText('김하진')).toBeInTheDocument(); }); });

16.3 에러 응답 테스트

describe('에러 처리', () => { it('API 실패 시 에러 메시지가 표시된다', async () => { // 에러 반환하도록 설정 vi.mocked(apiClient).mockRejectedValue(new Error('Network Error')); render(<AccountList />); await screen.findByText('데이터를 불러올 수 없습니다'); }); it('특정 요청만 실패하도록 설정', async () => { vi.mocked(apiClient).mockImplementation((endpoint: string) => { if (endpoint === '/bookmark_accounts') { return Promise.reject(new Error('Failed')); } // 나머지는 정상 응답 return Promise.resolve([]); }); }); });

16.4 POST 요청 Mock

vi.mocked(apiClient).mockImplementation((endpoint: string, options?: any) => { // GET 요청 if (!options?.method || options.method === "GET") { if (endpoint === "/my_accounts") { return Promise.resolve([ /* ... */ ]); } } // POST 요청 if (options?.method === "POST") { if (endpoint === "/bookmark_accounts") { return Promise.resolve({ success: true }); } if (endpoint === "/transfer") { return Promise.resolve({ transactionId: "123456" }); } } return Promise.reject(new Error("Unknown request")); });

17. 커스텀 훅 테스트

17.1 renderHook 사용법

커스텀 훅은 컴포넌트 안에서만 사용할 수 있습니다. renderHook은 훅을 테스트하기 위한 "가짜 컴포넌트"를 만들어줍니다.

renderHook이 없다면?

renderHook@testing-library/react **v13.1+**에서 기본 제공됩니다.

먼저 버전을 확인하세요:

pnpm list @testing-library/react
  • React 18 + 최신 RTL (v14+): renderHook 포함됨 ✅
  • v13.1 ~ v14 미만: renderHook 포함됨 ✅
  • v13.1 미만: 별도 설치 필요 → pnpm add -D @testing-library/react-hooks

대부분의 최신 프로젝트에서는 RTL 최신 버전을 쓰므로 추가 설치 없이 바로 사용 가능합니다.

import { useState } from "react"; import { renderHook, act } from "@testing-library/react"; // 테스트할 훅 function useCounter(initialValue = 0) { const [count, setCount] = useState(initialValue); const increment = () => setCount((c) => c + 1); const decrement = () => setCount((c) => c - 1); const reset = () => setCount(initialValue); return { count, increment, decrement, reset }; }
describe("useCounter", () => { it("초기값이 0이다", () => { const { result } = renderHook(() => useCounter()); expect(result.current.count).toBe(0); }); it("increment를 호출하면 count가 1 증가한다", () => { const { result } = renderHook(() => useCounter()); act(() => { result.current.increment(); }); expect(result.current.count).toBe(1); }); it("초기값을 지정할 수 있다", () => { const { result } = renderHook(() => useCounter(10)); expect(result.current.count).toBe(10); }); });

17.2 result.current 이해하기

const { result } = renderHook(() => useCounter()); // result.current = 훅의 반환값 // { count: 0, increment: fn, decrement: fn, reset: fn } console.log(result.current.count); // 0 act(() => { result.current.increment(); }); console.log(result.current.count); // 1 (업데이트됨!)

주의: act()로 감싸야 하는 이유

// ❌ act 없이 상태 변경하면 경고 발생 result.current.increment(); // ✅ act로 감싸야 함 act(() => { result.current.increment(); });

act()는 React에게 "이 안에서 상태 변경이 일어날 거야, 준비해!"라고 알려줍니다.

17.3 Provider가 필요한 훅 테스트

import { useContext } from "react"; // Context를 사용하는 훅 function useUser() { const context = useContext(UserContext); if (!context) { throw new Error("UserProvider 안에서 사용해야 합니다"); } return context; }
describe('useUser', () => { // wrapper로 Provider 제공 const wrapper = ({ children }: { children: React.ReactNode }) => ( <UserProvider> {children} </UserProvider> ); it('사용자 정보를 반환한다', () => { const { result } = renderHook(() => useUser(), { wrapper }); expect(result.current.name).toBeDefined(); }); it('Provider 없이 사용하면 에러가 발생한다', () => { expect(() => { renderHook(() => useUser()); // wrapper 없음 }).toThrow('UserProvider 안에서 사용해야 합니다'); }); });

17.4 실제 프로젝트 예시: 금액 검증 훅

// useAmountValidation.ts function useAmountValidation(amount: number) { const { data: myInfo } = useMyInfo(); const { data: limits } = useLimits(); // 1회 한도: 200만원 const oneTimeLimit = limits?.find((l) => l.type === "ONE_TIME_BALANCE")?.limit ?? 0; // 1일 한도: 500만원 - 오늘 송금액 const oneDayLimit = limits?.find((l) => l.type === "ONE_DAY_BALANCE")?.limit ?? 0; const todayUsed = myInfo?.transfer.one_day_amount ?? 0; const availableLimit = oneDayLimit - todayUsed; let isValid = true; let errorMessage = ""; if (amount <= 0) { isValid = false; } else if (amount > oneTimeLimit) { isValid = false; errorMessage = "200만원 송금 가능 (1회 한도 초과)"; } else if (amount > availableLimit) { isValid = false; errorMessage = "500만원 송금 가능"; } return { isValid, errorMessage }; }
// useAmountValidation.test.ts vi.mock('@/shared/api/client', () => ({ apiClient: vi.fn(), })); describe('useAmountValidation', () => { const wrapper = ({ children }) => ( <QueryClientProvider client={createTestQueryClient()}> {children} </QueryClientProvider> ); beforeEach(() => { vi.mocked(apiClient).mockImplementation((endpoint) => { if (endpoint === '/my_info') { return Promise.resolve({ transfer: { one_day_amount: 3500000 }, // 오늘 350만원 사용 }); } if (endpoint === '/limits') { return Promise.resolve([ { type: 'ONE_DAY_BALANCE', limit: 5000000 }, { type: 'ONE_TIME_BALANCE', limit: 2000000 }, ]); } }); }); it('금액이 0이면 유효하지 않다', async () => { const { result } = renderHook(() => useAmountValidation(0), { wrapper }); await waitFor(() => { expect(result.current.isValid).toBe(false); }); }); it('1회 한도(200만원) 이하면 유효하다', async () => { const { result } = renderHook( () => useAmountValidation(1500000), // 150만원 { wrapper } ); await waitFor(() => { expect(result.current.isValid).toBe(true); }); }); it('1회 한도(200만원) 초과면 에러 메시지를 반환한다', async () => { const { result } = renderHook( () => useAmountValidation(2500000), // 250만원 { wrapper } ); await waitFor(() => { expect(result.current.isValid).toBe(false); expect(result.current.errorMessage).toBe('200만원 송금 가능 (1회 한도 초과)'); }); }); });

18. Context와 Provider 테스트

18.1 테스트용 Provider 래퍼 만들기

실제 앱에서는 여러 Provider가 중첩됩니다:

// 실제 앱 <QueryClientProvider> <RouterProvider> <ToastProvider> <TransferProvider> <App /> </TransferProvider> </ToastProvider> </RouterProvider> </QueryClientProvider>

테스트에서도 같은 환경을 구성해야 합니다:

// test-utils.tsx import { render, RenderOptions } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; // 테스트용 QueryClient (완전한 격리 설정) // ⚠️ React Query v5 기준 예시입니다. v4를 사용한다면 gcTime → cacheTime으로 변경하세요. function createTestQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, // 실패해도 재시도 안 함 gcTime: 0, // 캐시 즉시 정리 (v4: cacheTime) staleTime: 0, // 항상 fresh fetch }, mutations: { retry: false, }, }, }); } // 참고: React Query v4는 logger 옵션을 지원하지만 v5에서는 제거됨 // 테스트 중 에러 로그를 숨기려면 setupTests.ts에서 console.error를 mock // vi.spyOn(console, 'error').mockImplementation(() => {}); // 모든 Provider를 포함하는 래퍼 function AllTheProviders({ children }: { children: React.ReactNode }) { const queryClient = createTestQueryClient(); return ( <MemoryRouter> <QueryClientProvider client={queryClient}> <ToastProvider> <TransferProvider> {children} </TransferProvider> </ToastProvider> </QueryClientProvider> </MemoryRouter> ); } // 커스텀 render 함수 function renderWithProviders( ui: React.ReactElement, options?: Omit<RenderOptions, 'wrapper'> ) { return render(ui, { wrapper: AllTheProviders, ...options }); } export { renderWithProviders, createTestQueryClient };

권장 파일 구조:

src/
├── test/
│   ├── test-utils.tsx      # renderWithProviders, createTestQueryClient
│   ├── setup.ts            # 전역 설정 (setupTests.ts 대체 가능)
│   └── mocks/
│       ├── handlers.ts     # MSW 핸들러 (선택)
│       └── server.ts       # MSW 서버 (선택)
├── setupTests.ts           # vitest 전역 설정
└── ...

사용:

import { renderWithProviders } from '@/test/test-utils'; it('페이지가 렌더링된다', async () => { renderWithProviders(<SelectAccountPage />); await screen.findByText('받을 계좌 선택'); });

18.2 라우터 상태 테스트

특정 경로나 state로 테스트해야 할 때:

import { MemoryRouter } from 'react-router-dom'; it('송금 완료 화면에서 성공 메시지가 표시된다', () => { const successState = { status: 'success', amount: 50000, recipient: { name: '김하진' }, }; render( <MemoryRouter initialEntries={[ { pathname: '/transfer/complete', state: successState, }, ]} > <TransferCompletePage /> </MemoryRouter> ); expect(screen.getByText('송금 완료')).toBeInTheDocument(); expect(screen.getByText('50,000원')).toBeInTheDocument(); });

18.3 Context 자체 테스트

// TransferContext.tsx import { createContext, useContext, useState } from 'react'; const TransferContext = createContext<TransferContextType | null>(null); export function TransferProvider({ children }) { const [recipient, setRecipient] = useState(null); const [amount, setAmount] = useState(0); const reset = () => { setRecipient(null); setAmount(0); }; return ( <TransferContext.Provider value={{ recipient, setRecipient, amount, setAmount, reset }}> {children} </TransferContext.Provider> ); } export function useTransfer() { const context = useContext(TransferContext); if (!context) { throw new Error('useTransfer must be used within a TransferProvider'); } return context; }
// TransferContext.test.tsx describe('TransferContext', () => { const wrapper = ({ children }) => ( <TransferProvider>{children}</TransferProvider> ); it('초기 상태가 올바르다', () => { const { result } = renderHook(() => useTransfer(), { wrapper }); expect(result.current.recipient).toBeNull(); expect(result.current.amount).toBe(0); }); it('setRecipient로 수취인을 설정할 수 있다', () => { const { result } = renderHook(() => useTransfer(), { wrapper }); act(() => { result.current.setRecipient({ name: '김하진', bank: '국민', accountNumber: '143-5678-9012', }); }); expect(result.current.recipient?.name).toBe('김하진'); }); it('reset을 호출하면 모든 상태가 초기화된다', () => { const { result } = renderHook(() => useTransfer(), { wrapper }); // 값 설정 act(() => { result.current.setRecipient({ name: '김하진' }); result.current.setAmount(50000); }); // 리셋 act(() => { result.current.reset(); }); expect(result.current.recipient).toBeNull(); expect(result.current.amount).toBe(0); }); it('Provider 없이 사용하면 에러가 발생한다', () => { expect(() => { renderHook(() => useTransfer()); }).toThrow('useTransfer must be used within a TransferProvider'); }); });

Part 6: 실전 적용

19. 무엇을 테스트해야 하는가

테스트를 처음 시작하면 "뭘 테스트해야 하지?"에서 멈추게 됩니다. 모든 것을 테스트할 수는 없으니, 우선순위를 정해야 합니다.

19.1 테스트 우선순위 가이드

1순위: 핵심 비즈니스 로직 🔴

- 결제/송금 금액 계산
- 로그인/인증 플로우
- 권한 체크 (가드)
- 할인/쿠폰 적용

2순위: 깨지면 큰일나는 것 🟠

- 금액 포맷팅 (1000 → 1,000원)
- 입력 검증 (한도 초과, 유효성)
- 조건부 렌더링 (로딩/에러/빈 상태)
- API 에러 처리

3순위: 자주 변경되는 곳 🟡

- 복잡한 조건 분기가 있는 컴포넌트
- 버그가 났던 곳 (회귀 방지)

테스트 안 해도 되는 것

- 단순 UI (색상, 레이아웃)
- 외부 라이브러리 동작
- 상수값, 타입 정의

19.2 실전 예시: 이 프로젝트에서 테스트한 것

✅ 테스트함:
- 금액 검증 로직 (1회 한도, 1일 한도)
- 즐겨찾기 추가/해제 기능
- 송금 성공/실패 분기
- 토스트 메시지 표시
- 계좌 목록 펼치기/접기

❌ 테스트 안 함:
- 버튼 색상
- 애니메이션
- CSS 스타일

19.3 버그가 났던 곳은 테스트로 박제하기

// 과거에 이런 버그가 있었다면: // "금액이 0원일 때 송금 버튼이 활성화되는 버그" // 이제 테스트로 박제: it("금액이 0원이면 확인 버튼이 비활성화된다", () => { // ... 테스트 코드 }); // → 같은 버그가 다시 발생하면 테스트가 잡아줌

19.4 테스트 크기의 3줄 룰

하나의 테스트는 Arrange → Act → Assert 구조가 한눈에 들어와야 합니다.

// ✅ 좋은 예: 각 단계가 명확 it('확인 버튼 클릭 시 송금이 실행된다', async () => { // Arrange - 준비 render(<TransferPage />); // Act - 실행 await userEvent.click(screen.getByRole('button', { name: '확인' })); // Assert - 검증 expect(mockTransfer).toHaveBeenCalledWith({ amount: 10000 }); }); // ❌ 나쁜 예: 너무 많은 것을 검증 it('송금 플로우 전체 테스트', async () => { // 20줄의 setup... // 10번의 click... // 15개의 expect... // → 실패하면 어디가 문제인지 찾기 어려움 });

💡 : 테스트가 길어지면 여러 개로 쪼개세요. "이 테스트가 실패하면 뭐가 문제인지 한 문장으로 말할 수 있나?"를 기준으로 판단합니다.


20. API Mocking 전략 선택하기

20.1 두 가지 방식

| 방식 | 사용 시점 | 장점 | 단점 | | ----------------------------- | -------------------------- | ----------------- | -------------------- | | 모듈 mocking (vi.mock) | 단위 테스트, 훅 테스트 | 간단, 빠름 | 실제 네트워크와 다름 | | MSW (Mock Service Worker) | 통합 테스트, 페이지 테스트 | 실제 fetch 그대로 | 설정 복잡 |

20.2 이 글에서 다룬 방식: 모듈 mocking

// 단순하고 빠름 - 대부분의 경우 충분 vi.mock("@/shared/api/client", () => ({ apiClient: vi.fn(), }));

20.3 MSW는 언제 쓰나요?

여러 곳에서 fetchaxios를 직접 호출하는 경우, MSW가 더 적합합니다:

// MSW 예시 (참고용) import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; const server = setupServer( http.get("/api/users", () => { return HttpResponse.json([{ id: 1, name: "김철수" }]); }), ); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());

모듈 mocking vs MSW 선택 기준:

| 상황 | 권장 방식 | | ---------------------------------- | ------------ | | API 클라이언트가 한 곳에서 관리됨 | 모듈 mocking | | 훅/유틸 단위 테스트 | 모듈 mocking | | 여러 모듈이 fetch/axios 직접 호출 | MSW | | 상태코드/헤더/지연 시뮬레이션 필요 | MSW | | 네트워크 레벨 에러 재현 필요 | MSW |

: vi.mock 기반 모듈 mocking은 빠르고 단순하지만, 호출 지점이 여러 군데로 퍼지면 유지보수가 급격히 어려워집니다. 여러 모듈이 fetch/axios를 직접 호출하거나, 상태코드/헤더/지연/에러를 네트워크 레벨로 재현해야 한다면 MSW가 더 적합합니다.

실전 프로젝트에서의 선택 가이드:

프로젝트 구조를 먼저 확인하세요:

✅ 모듈 mocking이 깔끔한 경우:
src/shared/api/client.ts  ← API 호출이 여기로 집중
  └── 모든 컴포넌트/훅이 이 client를 import

→ vi.mock('@/shared/api/client') 하나로 해결!

⚠️ MSW가 더 적합한 경우:
src/features/user/api.ts      ← fetch 직접 호출
src/features/account/api.ts   ← axios 직접 호출
src/utils/fetcher.ts          ← 또 다른 fetch 래퍼

→ mock해야 할 모듈이 계속 늘어남
→ MSW로 네트워크 레벨에서 한 번에 처리하는 게 유지보수에 유리

처음에는 모듈 mocking으로 시작하고, 프로젝트가 커지면 MSW 도입을 고려하세요.


21. 실제 프로젝트 테스트 예시

21.1 페이지 테스트 전체 예시

먼저 가장 간단한 형태를 보여드립니다. 아래 구조만 기억하세요:

// ✅ 최소 동작 예시: 페이지 테스트의 뼈대 vi.mock('@/shared/api/client', () => ({ apiClient: vi.fn(), })); // 매 테스트마다 새 QueryClient 생성 (캐시 격리) function createTestQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false } }, }); } function renderPage() { return render( <QueryClientProvider client={createTestQueryClient()}> <MyPage /> </QueryClientProvider> ); } it('페이지가 렌더링된다', async () => { vi.mocked(apiClient).mockResolvedValue({ data: [] }); renderPage(); expect(await screen.findByRole('heading')).toBeInTheDocument(); });

이제 실제 프로젝트 수준의 전체 예시를 살펴봅니다:

// select-account.test.tsx import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import { SelectAccountPage } from './select-account'; import { apiClient } from '@/shared/api/client'; import { ToastProvider } from '@/shared/ui/ToastContext'; import { TransferProvider } from '@/features/transfer'; // API Mock vi.mock('@/shared/api/client', () => ({ apiClient: vi.fn(), })); // Mock 데이터 const mockMyAccounts = [ { id: 1, account_number: '110-1234-5678', holder_name: '김준태', balance: 1000000, bank: { code: '088', name: '신한', image_url: 'https://example.com/shinhan.png', bank_nickname: '월급통장', }, }, { id: 2, account_number: '110-2345-6789', holder_name: '김준태', balance: 2000000, bank: { code: '090', name: '카카오뱅크', image_url: 'https://example.com/kakao.png', bank_nickname: '', // 별명 없음 }, }, { id: 3, account_number: '110-3456-7890', holder_name: '김준태', balance: 3000000, bank: { code: '004', name: '국민', image_url: 'https://example.com/kb.png', bank_nickname: '적금', }, }, ]; const mockRecentAccounts = [ { id: 1, account_number: '143-5678-9012', holder_name: '김하진', bank: { code: '004', image_url: 'https://example.com/kb.png', }, }, ]; const mockBookmarks = [ { id: 1, bank_account_number: '110-1234-5678' }, ]; // 테스트 유틸 function createTestQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); } function renderWithProviders(ui: React.ReactElement) { const queryClient = createTestQueryClient(); return render( <MemoryRouter> <QueryClientProvider client={queryClient}> <ToastProvider> <TransferProvider> {ui} </TransferProvider> </ToastProvider> </QueryClientProvider> </MemoryRouter> ); } function setupMocks() { vi.mocked(apiClient).mockImplementation((endpoint: string, options?: any) => { if (endpoint === '/my_accounts') return Promise.resolve(mockMyAccounts); if (endpoint === '/recents_transfer_accounts') return Promise.resolve(mockRecentAccounts); if (endpoint === '/bookmark_accounts') { if (options?.method === 'POST') { return Promise.resolve({ success: true }); } return Promise.resolve(mockBookmarks); } return Promise.reject(new Error(`Unknown endpoint: ${endpoint}`)); }); } // 테스트 시작 describe('SelectAccountPage', () => { beforeEach(() => { setupMocks(); }); afterEach(() => { vi.clearAllMocks(); }); describe('페이지 헤더', () => { it('뒤로가기 버튼이 렌더링된다', async () => { renderWithProviders(<SelectAccountPage />); // ✅ findBy: 단순 요소 출현 확인 expect(await screen.findByRole('button', { name: /뒤로/i })).toBeInTheDocument(); }); }); describe('내 계좌 섹션', () => { it('섹션 제목이 표시된다', async () => { renderWithProviders(<SelectAccountPage />); expect(await screen.findByText('내 계좌')).toBeInTheDocument(); }); it('초기에는 2개 계좌만 표시된다', async () => { renderWithProviders(<SelectAccountPage />); // 데이터 로딩 대기 await screen.findByText('내 계좌'); // ✅ within으로 특정 영역 내 요소 카운트 (아래 22.3 참고) const myAccountSection = screen.getByRole('region', { name: '내 계좌' }); const accounts = within(myAccountSection).getAllByRole('listitem'); expect(accounts).toHaveLength(2); }); it('펼치기 버튼에 숨겨진 계좌 수가 표시된다', async () => { renderWithProviders(<SelectAccountPage />); expect(await screen.findByText('+1')).toBeInTheDocument(); // 3개 중 2개 표시, 1개 숨김 }); it('펼치기 버튼을 클릭하면 모든 계좌가 표시된다', async () => { const user = userEvent.setup(); renderWithProviders(<SelectAccountPage />); const expandButton = await screen.findByText('+1'); await user.click(expandButton); // ✅ waitFor: 개수 변화 등 복잡한 조건 const myAccountSection = screen.getByRole('region', { name: '내 계좌' }); await waitFor(() => { const accounts = within(myAccountSection).getAllByRole('listitem'); expect(accounts).toHaveLength(3); // 모든 계좌 표시 }); }); it('별명이 있으면 별명이 표시된다', async () => { renderWithProviders(<SelectAccountPage />); expect(await screen.findByText('월급통장')).toBeInTheDocument(); }); it('별명이 없으면 "별명 미설정"이 표시된다', async () => { const user = userEvent.setup(); renderWithProviders(<SelectAccountPage />); // 펼쳐서 카카오뱅크 계좌 표시 const expandButton = await screen.findByText('+1'); await user.click(expandButton); await waitFor(() => { expect(screen.getByText('별명 미설정')).toBeInTheDocument(); }); }); }); describe('최근 송금 섹션', () => { it('최근 송금 계좌가 표시된다', async () => { renderWithProviders(<SelectAccountPage />); expect(await screen.findByText('김하진')).toBeInTheDocument(); }); }); describe('즐겨찾기 기능', () => { it('즐겨찾기된 계좌는 활성화 상태로 표시된다', async () => { renderWithProviders(<SelectAccountPage />); // 110-1234-5678 계좌가 즐겨찾기됨 expect(await screen.findByRole('button', { name: '즐겨찾기 해제' })).toBeInTheDocument(); }); it('즐겨찾기 버튼 클릭 시 토글된다', async () => { const user = userEvent.setup(); renderWithProviders(<SelectAccountPage />); // 특정 계좌 행을 찾아서 그 안의 버튼 클릭 const accountItem = await screen.findByText('김하진'); const accountRow = accountItem.closest('li') as HTMLElement; const addButton = within(accountRow).getByRole('button', { name: '즐겨찾기 추가' }); await user.click(addButton); // API 호출 확인 - 기본 패턴 expect(apiClient).toHaveBeenCalledWith( '/bookmark_accounts', expect.objectContaining({ method: 'POST', }) ); // body 내용 검증이 필요하다면 mock.calls로 직접 확인 (더 튼튼한 패턴) // toHaveBeenCalledWith는 인자 개수가 바뀌면 깨질 수 있음 const postCall = vi.mocked(apiClient).mock.calls.find( ([url, options]) => url === '/bookmark_accounts' && options?.method === 'POST' ); expect(postCall).toBeDefined(); const body = JSON.parse(postCall![1]?.body as string); expect(body).toHaveProperty('bank_account_number'); expect(typeof body.bank_account_number).toBe('string'); }); }); describe('에러 처리', () => { it('즐겨찾기 추가 실패 시 토스트가 표시된다', async () => { const user = userEvent.setup(); // 즐겨찾기 추가만 실패하도록 설정 vi.mocked(apiClient).mockImplementation((endpoint, options) => { if (options?.method === 'POST') { return Promise.reject(new Error('Failed')); } if (endpoint === '/my_accounts') return Promise.resolve(mockMyAccounts); if (endpoint === '/recents_transfer_accounts') return Promise.resolve(mockRecentAccounts); if (endpoint === '/bookmark_accounts') return Promise.resolve([]); return Promise.reject(new Error('Unknown')); }); renderWithProviders(<SelectAccountPage />); // 특정 계좌 행을 찾아서 그 안의 버튼 클릭 const accountItem = await screen.findByText('김준태'); const accountRow = accountItem.closest('li') as HTMLElement; const addButton = within(accountRow).getByRole('button', { name: '즐겨찾기 추가' }); await user.click(addButton); // ✅ waitFor: role="alert"가 나타나고 특정 텍스트를 포함하는지 확인 await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent('즐겨찾기 추가에 실패했습니다'); }); }); }); describe('접근성', () => { it('계좌 목록이 리스트 구조로 렌더링된다', async () => { renderWithProviders(<SelectAccountPage />); // 데이터 로딩 대기 await screen.findByText('내 계좌'); // 동기적으로 확인 expect(screen.getAllByRole('list').length).toBeGreaterThan(0); expect(screen.getAllByRole('listitem').length).toBeGreaterThan(0); }); it('버튼에 aria-label이 있다', async () => { renderWithProviders(<SelectAccountPage />); const backButton = await screen.findByRole('button', { name: /뒤로/i }); expect(backButton).toHaveAttribute('aria-label'); }); }); });

22. 테스트 작성 팁과 주의사항

22.1 테스트 작성 체크리스트

테스트 전:

  • [ ] 무엇을 테스트할지 명확히 정의했나?
  • [ ] 테스트 케이스가 사용자 관점인가?
  • [ ] 테스트 설명이 한글로 명확한가?

테스트 중:

  • [ ] getByRole을 우선 사용했나?
  • [ ] 비동기 작업에 await를 사용했나?
  • [ ] Mock이 올바르게 설정됐나?

테스트 후:

  • [ ] 테스트가 독립적으로 실행되나?
  • [ ] Mock이 제대로 정리되나?
  • [ ] 에러 케이스도 테스트했나?

22.2 좋은 테스트 vs 나쁜 테스트

❌ 나쁜 테스트: 구현 세부사항 테스트

// 나쁜 예: 내부 구현에 의존 it("state가 업데이트된다", () => { const { result } = renderHook(() => useCounter()); expect(result.current.state.count).toBe(0); // 내부 state 구조에 의존 expect(result.current.setCount).toBeDefined(); // 내부 함수 이름에 의존 });

✅ 좋은 테스트: 동작(행위) 테스트

// 좋은 예: 사용자가 보는 결과에 집중 it('증가 버튼을 클릭하면 숫자가 1 증가한다', async () => { const user = userEvent.setup(); render(<Counter />); expect(screen.getByText('0')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: '증가' })); expect(screen.getByText('1')).toBeInTheDocument(); });

22.3 within()으로 범위 좁히기

페이지에 같은 텍스트가 여러 번 나타날 때, getAllByText로 개수를 세면 불안정합니다:

// ❌ 나쁜 예: 전체 페이지에서 검색 - 다른 영역에 같은 텍스트가 있으면 실패 const accounts = screen.getAllByText("김준태"); expect(accounts).toHaveLength(2); // 헤더에도 "김준태"가 있다면? // ✅ 좋은 예: within()으로 특정 영역 내에서만 검색 import { within } from "@testing-library/react"; const myAccountSection = screen.getByRole("region", { name: "내 계좌" }); const accounts = within(myAccountSection).getAllByRole("listitem"); expect(accounts).toHaveLength(2);

⚠️ 중요: getByRole('region')이 동작하려면 컴포넌트에 접근성 속성이 필요합니다!

위 테스트가 동작하려면 컴포넌트가 이렇게 작성되어 있어야 합니다:

// MyAccountSection.tsx - 컴포넌트 코드 // aria-labelledby로 section을 region으로 노출 (권장) function MyAccountSection({ accounts }) { return ( <section aria-labelledby="my-accounts-title"> <h2 id="my-accounts-title">내 계좌</h2> <ul> {accounts.map((account) => ( <li key={account.id}>{account.name}</li> ))} </ul> </section> ); }
// MyAccountSection.test.tsx - 테스트 코드 it('내 계좌 목록이 2개 표시된다', () => { render(<MyAccountSection accounts={mockAccounts} />); // 이제 getByRole('region')이 동작함! const section = screen.getByRole('region', { name: '내 계좌' }); const items = within(section).getAllByRole('listitem'); expect(items).toHaveLength(2); });

aria-label vs aria-labelledby:

| 방식 | 예시 | 언제 사용 | | ----------------- | -------------------------------------- | ----------------------- | | aria-label | <section aria-label="내 계좌"> | 짧은 라벨, h2가 없을 때 | | aria-labelledby | <section aria-labelledby="title-id"> | 기존 헤딩 재사용 (권장) |

aria-labelledby는 기존 헤딩을 재사용하므로 라벨 중복을 피할 수 있습니다.

⚠️ aria-label 또는 aria-labelledby가 없으면 <section>은 role="region"으로 노출되지 않습니다. 테스트에서 getByRole('region')이 실패하면 먼저 컴포넌트에 접근성 속성이 있는지 확인하세요.

레거시 코드가 <div>라면?

  • <div>는 기본적으로 role이 없습니다
  • 방법 1: <div role="region" aria-label="내 계좌">로 직접 role 부여
  • 방법 2: getByRole 대신 getByTestId 또는 다른 쿼리 전략 사용
  • 권장: 가능하면 시맨틱 태그(<section>)로 리팩토링

within()은 특정 컨테이너 내에서만 쿼리를 실행하게 해줍니다:

// 모달 내에서만 버튼 찾기 const modal = screen.getByRole("dialog"); const confirmButton = within(modal).getByRole("button", { name: "확인" }); // 특정 폼 내에서만 input 찾기 // ⚠️ form도 section과 마찬가지로 aria-labelledby가 있어야 getByRole('form')이 동작함! // <form aria-labelledby="login-title"><h1 id="login-title">로그인</h1>...</form> const loginForm = screen.getByRole("form", { name: "로그인" }); const emailInput = within(loginForm).getByLabelText("이메일");

closest() 사용 시 주의사항:

// ⚠️ closest('li')는 DOM 구조 변경에 취약 const accountItem = screen.getByText("김하진"); const row = accountItem.closest("li"); // <li>가 <div>로 바뀌면 깨짐! // ✅ 더 안정적인 패턴: 섹션을 먼저 좁힌 후 closest 사용 const section = screen.getByRole("region", { name: "최근 송금" }); const item = within(section).getByText("김하진").closest("li")!; const btn = within(item as HTMLElement).getByRole("button", { name: "즐겨찾기 추가", });

핵심: 범위를 먼저 좁히고(section → item) 마지막에 closest를 쓰면 안정성이 올라갑니다.

22.4 QueryClient 캐시 격리

React Query를 사용할 때, 테스트 간 캐시가 공유되면 예기치 않은 결과가 발생합니다:

// ❌ 나쁜 예: 같은 QueryClient 재사용 const queryClient = new QueryClient(); describe('MyComponent', () => { it('첫 번째 테스트', async () => { render(<MyComponent />, { wrapper: ... }); // 캐시에 데이터가 저장됨 }); it('두 번째 테스트', async () => { render(<MyComponent />, { wrapper: ... }); // 첫 번째 테스트의 캐시가 남아있음! 😱 }); }); // ✅ 좋은 예: 매 테스트마다 새 QueryClient 생성 function createTestQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, // 테스트에서는 재시도 불필요 }, }, }); } describe('MyComponent', () => { let queryClient: QueryClient; beforeEach(() => { queryClient = createTestQueryClient(); }); afterEach(() => { queryClient.clear(); // 명시적으로 캐시 정리 }); it('첫 번째 테스트', async () => { render(<MyComponent />, { wrapper: ({ children }) => ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> ), }); }); });

핵심: renderWithProviders 유틸에서 매번 새 QueryClient를 생성하면 자동으로 격리됩니다.

22.5 테스트 격리

각 테스트는 독립적이어야 합니다.

// ❌ 나쁜 예: 테스트 간 상태 공유 let count = 0; it("첫 번째 테스트", () => { count += 1; expect(count).toBe(1); }); it("두 번째 테스트", () => { // count가 1인 상태에서 시작 expect(count).toBe(0); // 실패! }); // ✅ 좋은 예: 각 테스트마다 초기화 describe("Counter", () => { beforeEach(() => { vi.clearAllMocks(); }); it("첫 번째 테스트", () => {}); it("두 번째 테스트", () => {}); });

Mock 정리 함수 비교:

| 함수 | 동작 | 사용 시점 | | ---------------------- | --------------------------------- | -------------------------------------- | | vi.clearAllMocks() | 호출 기록만 지움 (mock 구현 유지) | 대부분의 경우 (가장 많이 사용) | | vi.resetAllMocks() | 호출 기록 + mock 구현도 초기화 | mock 반환값을 매번 다시 설정할 때 | | vi.restoreAllMocks() | spyOn으로 감싼 원본 함수 복구 | vi.spyOn 사용 후 원본 복구 필요할 때 |

const mockFn = vi.fn().mockReturnValue("hello"); mockFn(); vi.clearAllMocks(); // mockFn.mock.calls = [] (기록 삭제) // mockFn() → 'hello' (구현 유지!) vi.resetAllMocks(); // mockFn.mock.calls = [] (기록 삭제) // mockFn() → undefined (구현도 초기화!)

vi.restoreAllMocks()가 필요한 경우 - spyOn과 함께:

// ✅ spyOn은 원본 함수를 "감시"하면서 mock으로 대체함 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); // 테스트 중... console.error("에러 발생"); expect(consoleSpy).toHaveBeenCalledWith("에러 발생"); // ❌ clearAllMocks, resetAllMocks로는 원본 복구 안 됨 // console.error가 계속 빈 함수로 남아있음! // ✅ restoreAllMocks로 원본 console.error 복구 vi.restoreAllMocks(); // 이제 console.error가 정상 동작

핵심: vi.spyOn은 원본을 건드리므로 반드시 restoreAllMocks()로 복구해야 합니다. 그렇지 않으면 다른 테스트에서 console.error가 계속 무시됩니다.

22.6 테스트 실행 순서에 의존하지 않기

// ❌ 나쁜 예: 순서 의존 describe("User Flow", () => { it("1. 로그인한다", () => { // 로그인 후 토큰 저장 }); it("2. 프로필을 본다", () => { // 위에서 저장한 토큰 사용 (순서 의존!) }); }); // ✅ 좋은 예: 각 테스트가 독립적 describe("User Flow", () => { it("로그인한다", () => { // 완전히 독립적인 테스트 }); it("프로필을 본다", () => { // 필요한 것은 beforeEach에서 설정 }); });

22.7 flaky 테스트 피하기

flaky 테스트: 같은 코드인데 때때로 성공하고 때때로 실패하는 테스트

// ❌ flaky: 타이밍에 의존 it('3초 후 사라진다', async () => { render(<Toast />); await new Promise((r) => setTimeout(r, 3000)); // 실제 3초 대기 expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); // ✅ 안정적: 가짜 타이머 사용 it('3초 후 사라진다', async () => { vi.useFakeTimers(); render(<Toast />); act(() => { vi.advanceTimersByTime(3000); }); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); vi.useRealTimers(); });

22.8 적절한 추상화 수준 유지

// ❌ 너무 세부적: 모든 것을 테스트 it('버튼의 background-color가 #007bff다', () => { expect(button).toHaveStyle({ backgroundColor: '#007bff' }); }); // ❌ 너무 포괄적: 아무것도 검증 안 함 it('컴포넌트가 렌더링된다', () => { render(<ComplexComponent />); // 끝? }); // ✅ 적절한 수준: 사용자에게 의미 있는 것 it('비활성화된 버튼은 클릭해도 반응하지 않는다', async () => { const handleClick = vi.fn(); render(<Button disabled onClick={handleClick}>클릭</Button>); await user.click(screen.getByRole('button')); expect(handleClick).not.toHaveBeenCalled(); });

22.9 테스트 커버리지

vitest.config.ts에 커버리지 설정 추가:

// vitest.config.ts export default defineConfig({ test: { // ... 기존 설정 coverage: { provider: "v8", // v8 또는 istanbul reporter: ["text", "html"], // 터미널 출력 + HTML 리포트 // 아래는 선택 옵션 // include: ['src/**/*.{ts,tsx}'], // exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**'], }, }, });

실행:

pnpm test:coverage

결과 예시:

--------------------|---------|----------|---------|---------|
File                | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files           |   85.71 |    78.57 |   88.89 |   85.71 |
 Button.tsx         |     100 |      100 |     100 |     100 |
 LoginForm.tsx      |   78.57 |    66.67 |   83.33 |   78.57 |
--------------------|---------|----------|---------|---------|

: reporter: ['html']을 추가하면 coverage/index.html에서 어떤 라인이 테스트되지 않았는지 시각적으로 확인할 수 있습니다.

커버리지 해석:

  • Stmts (Statements): 실행된 코드 문장 비율
  • Branch: 실행된 분기(if/else) 비율
  • Funcs: 실행된 함수 비율
  • Lines: 실행된 코드 라인 비율

100% 커버리지가 목표가 아닙니다!

  • 중요한 비즈니스 로직: 높은 커버리지
  • 단순한 UI: 적당한 커버리지

22.10 테스트 파일 위치와 네이밍 컨벤션

테스트 파일을 어디에 두고 어떻게 이름 짓는지는 팀마다 다르지만, 일관성이 중요합니다.

네이밍 패턴:

Component.test.tsx    # 단위 테스트
Component.spec.tsx    # 동일 (spec = specification)
Component.test.ts     # 훅/유틸 테스트

위치 전략 1: 소스 코드 옆 (Co-location)

src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.test.tsx    # ✅ 바로 옆에
│   │   └── Button.module.scss
│   └── LoginForm/
│       ├── LoginForm.tsx
│       └── LoginForm.test.tsx

장점:

  • 파일 찾기 쉬움
  • 관련 코드가 함께 있음
  • 리팩토링 시 함께 이동

위치 전략 2: 별도 tests 폴더

src/
├── components/
│   ├── Button.tsx
│   └── LoginForm.tsx
├── __tests__/
│   ├── Button.test.tsx
│   └── LoginForm.test.tsx

장점:

  • 소스 폴더가 깔끔함
  • 테스트 파일만 모아서 관리

실전 권장 (이 프로젝트에서 사용):

src/
├── entities/
│   └── account/
│       ├── api/
│       │   └── useMyAccounts.ts
│       ├── model/
│       │   └── types.ts
│       └── api.test.ts         # slice 단위 테스트
├── features/
│   └── transfer/
│       ├── ui/
│       │   └── TransferForm.tsx
│       └── transfer.test.tsx   # feature 단위 테스트
└── shared/
    └── lib/
        ├── formatNumber.ts
        └── formatNumber.test.ts # 유틸 단위 테스트

vitest.config.ts 설정:

export default defineConfig({ test: { include: ["src/**/*.{test,spec}.{ts,tsx}"], // 또는 특정 패턴만 // include: ['src/**/__tests__/**/*.{ts,tsx}'], }, });

부록: Next.js App Router 테스트

Next.js(특히 App Router)를 사용한다면 몇 가지 추가 설정이 필요합니다.

이 부록은 Vitest로 Next.js를 테스트하는 방법을 다룹니다. 아래 설정대로 하면 충분히 동작합니다.

Next.js에서 Jest vs Vitest 선택

| 기준 | Jest | Vitest | | -------------------------- | -------------------------------- | ------------------------------------------------- | | 공식 지원 | ✅ Next.js 공식 문서에 예시 풍부 | ⚠️ 커뮤니티 중심, 직접 설정 필요 | | 설정 난이도 | 쉬움 (next/jest 프리셋 제공) | 보통 (next/image, next/navigation mock 직접 구성) | | 속도 | 보통 | 빠름 (Vite 기반) | | Vite 프로젝트와 일관성 | X | ✅ 같은 설정 재사용 가능 | | 팀 레퍼런스 | 많음 | 적음 (증가 추세) |

권장:

  • Next.js가 메인 + 팀에 Jest 경험자 많음 → Jest 선택이 현실적
  • Vite 기반 프로젝트와 설정 통일 원함 + 직접 설정 감수 가능 → Vitest

참고로 Next.js 공식 문서와 커뮤니티에서는 Jest 예시가 더 많습니다. 팀에서 Jest를 선택한다면 Next.js Testing 가이드의 Jest 섹션을 참고하세요.

next/navigation Mock

App Router의 useRouter, useSearchParams, usePathname 등을 Mock해야 합니다:

// setupTests.ts 또는 테스트 파일 상단 import { vi } from "vitest"; // ✅ 권장: vi.fn()으로 선언하여 테스트별 mockReturnValue 가능 vi.mock("next/navigation", () => ({ useRouter: vi.fn(), useSearchParams: vi.fn(), usePathname: vi.fn(), useParams: vi.fn(), redirect: vi.fn(), notFound: vi.fn(), useSelectedLayoutSegment: vi.fn(), useSelectedLayoutSegments: vi.fn(), }));

테스트 파일에서 기본값 설정:

// 테스트 파일 import { useRouter, useSearchParams, usePathname, useParams, } from "next/navigation"; beforeEach(() => { // 기본 mock 반환값 설정 vi.mocked(useRouter).mockReturnValue({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), }); vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams("")); vi.mocked(usePathname).mockReturnValue("/"); vi.mocked(useParams).mockReturnValue({}); });

왜 이 방식이 좋은가?

  • useRouter: () => ({ push: vi.fn() }) 방식은 매 호출마다 새 vi.fn()이 생성되어 테스트에서 추적 불가
  • useRouter: vi.fn() + mockReturnValue는 테스트별로 다른 반환값 설정 가능, push 호출 검증도 용이

useSearchParams 타입 참고: 실제 Next.js의 useSearchParams()ReadonlyURLSearchParams를 반환합니다. 테스트에서는 new URLSearchParams()로 충분히 동작하지만, 타입을 정확히 맞추려면:

vi.mocked(useSearchParams).mockReturnValue( new URLSearchParams("?page=1") as unknown as ReadonlyURLSearchParams, );

대부분의 테스트에서는 URLSearchParams로 충분합니다.

.get() 분기 테스트 예시:

// 컴포넌트: const query = searchParams.get('q'); // query 값에 따라 다른 동작을 테스트할 때: it("검색어가 있으면 결과를 표시한다", () => { vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams("q=테스트")); render(<SearchPage />); expect(screen.getByText(/검색 결과/)).toBeInTheDocument(); }); it("검색어가 없으면 빈 상태를 표시한다", () => { vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams("")); render(<SearchPage />); expect(screen.getByText(/검색어를 입력하세요/)).toBeInTheDocument(); });

참고: redirectnotFound는 Server Component/Route Handler에서 주로 사용됩니다. Client Component에서 이들을 직접 호출하는 경우는 드물지만, mock 해두면 import 에러를 방지할 수 있습니다.

next/image Mock

next/image는 테스트에서 자주 문제가 됩니다. JSX로 mock하는 것이 가장 직관적입니다:

// setupTests.tsx (JSX를 사용하므로 .tsx 확장자 필수!) import React from "react"; import { vi } from "vitest"; vi.mock("next/image", () => ({ default: ({ src, alt, ...props }: any) => { // src가 객체(StaticImport)일 수 있으므로 처리 const imgSrc = typeof src === "string" ? src : src?.src; // eslint-disable-next-line @next/next/no-img-element return <img src={imgSrc} alt={alt} {...props} />; }, }));
// vitest.config.ts setupFiles: ['./src/setupTests.tsx'], // .ts → .tsx로 변경 필요

💡 JSX 없이 작성하고 싶다면:

DOM API를 직접 사용할 수 있지만, React 렌더러와 호환성 문제가 생길 수 있습니다:

// setupTests.ts (JSX 미사용 - 일부 환경에서 문제 가능) vi.mock("next/image", () => ({ default: (props: any) => { const img = document.createElement("img"); Object.assign(img, props); return img; }, }));

Link와 router.push 테스트

Next.js <Link>는 클릭 시 자체적으로 네비게이션합니다. push mock을 검증하는 것은 올바르지 않습니다.

🎯 핵심 원칙:

  • RTL(단위 테스트)에서는 href 속성을 검증 — 실제 URL 이동은 jsdom에서 불가능
  • E2E 테스트에서는 URL 이동을 검증 — Playwright/Cypress가 실제 브라우저 탐색을 처리
// ❌ 잘못된 예: Link 클릭 후 push 검증 await user.click(screen.getByRole('link')); expect(push).toHaveBeenCalled(); // Link는 push를 호출하지 않음! // ✅ Link 테스트: 렌더링 + 접근성 + href 검증 (RTL의 역할) it('상세보기 링크가 올바른 경로를 가리킨다', () => { render(<MyComponent />); // 1. 링크가 렌더링되어 접근 가능한지 (role="link") const link = screen.getByRole('link', { name: '상세보기' }); expect(link).toBeInTheDocument(); // 2. href가 올바른지 expect(link).toHaveAttribute('href', '/detail/1'); // ❌ 클릭 후 URL 이동 검증은 RTL에서 하지 않음 // await user.click(link); → jsdom에서는 실제 이동 안 됨 // → 실제 URL 변경 검증은 E2E 테스트의 몫! }); // ✅ router.push 테스트: 버튼/이벤트에서 프로그래매틱 이동 시 it('삭제 후 목록으로 이동한다', async () => { const push = vi.fn(); vi.mocked(useRouter).mockReturnValue({ push } as any); const user = userEvent.setup(); render(<DeleteButton />); await user.click(screen.getByRole('button', { name: '삭제' })); expect(push).toHaveBeenCalledWith('/list'); });

| 테스트 유형 | 검증 대상 | 검증 방법 | | ----------- | ---------------- | ----------------------------------------------- | | RTL (단위) | <Link> | expect(link).toHaveAttribute('href', '/path') | | RTL (단위) | router.push() | expect(push).toHaveBeenCalledWith('/path') | | E2E | 실제 페이지 이동 | expect(page).toHaveURL('/path') |

요약 - "Link 클릭 테스트는 안 해도 되나요?"

RTL에서 Link를 테스트할 때는:

  1. getByRole('link')로 링크가 렌더링되고 접근 가능한지 확인
  2. toHaveAttribute('href', '/path')올바른 경로를 가리키는지 확인
  3. ❌ 클릭 후 URL 이동은 jsdom에서 동작하지 않으므로 검증하지 않음

버튼/핸들러에서 router.push()를 직접 호출하는 경우만 push mock을 검증하세요. 실제 페이지 이동 검증은 E2E 테스트의 몫입니다.

Server Component 테스트

React Server Component(RSC)는 서버에서만 실행되므로 jsdom 기반 RTL에서 직접 테스트할 수 없습니다. 이건 "회피"가 아니라 테스트 전략의 분리입니다.

| 테스트 대상 | 테스트 도구 | 이유 | | ------------------------------ | ---------------------- | ---------------------------------- | | Server Component 전체 | E2E (Playwright) | 실제 서버 렌더링 필요 | | SC 내부 비즈니스 로직 | Vitest (순수 함수) | 로직을 함수로 분리하면 테스트 가능 | | SC가 호출하는 Client Component | RTL | 'use client' 컴포넌트는 RTL로 충분 |

실전 전략:

// 1️⃣ 비즈니스 로직을 순수 함수로 분리 → Vitest로 테스트 // utils/formatUser.ts export function formatUserDisplay(user: User) { return `${user.name} (${user.email})`; } // utils/formatUser.test.ts it('사용자 정보를 포맷팅한다', () => { const user = { name: '김철수', email: 'kim@example.com' }; expect(formatUserDisplay(user)).toBe('김철수 (kim@example.com)'); }); // 2️⃣ UI 로직은 Client Component로 분리 → RTL로 테스트 // UserProfileClient.tsx (Client Component) 'use client'; export function UserProfileClient({ user }: { user: User }) { return <div>{user.name}</div>; } // UserProfileClient.test.tsx it('사용자 이름이 표시된다', () => { render(<UserProfileClient user={{ name: '김철수' }} />); expect(screen.getByText('김철수')).toBeInTheDocument(); }); // 3️⃣ Server Component 전체 동작 → E2E 테스트 // e2e/user-profile.spec.ts (Playwright) test('사용자 프로필 페이지가 정상 렌더링된다', async ({ page }) => { await page.goto('/user/1'); await expect(page.getByText('김철수')).toBeVisible(); });

핵심: Server Component를 "테스트할 수 없다"가 아니라, 적절한 도구로 적절한 계층을 테스트하는 것입니다.

  • 로직 → 순수 함수 → Vitest
  • UI 인터랙션 → Client Component → RTL
  • 통합 동작 → E2E → Playwright

참고: Next.js 테스트는 공식 문서의 Testing 가이드를 함께 참고하세요.


부록: jsdom에 없는 브라우저 API 대응

jsdom은 모든 브라우저 API를 지원하지 않습니다. 테스트 중 갑자기 에러가 나면 이 목록을 확인하세요. 아

자주 필요한 Polyfill/Mock

setupTests.ts에 필요한 것만 추가하세요:

// setupTests.ts import { vi } from "vitest"; import "@testing-library/jest-dom/vitest"; // ResizeObserver (차트, 가상 스크롤 등에서 필요) global.ResizeObserver = vi.fn().mockImplementation(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn(), })); // IntersectionObserver (무한 스크롤, lazy loading 등에서 필요) global.IntersectionObserver = vi.fn().mockImplementation(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn(), root: null, rootMargin: "", thresholds: [], })); // matchMedia (반응형 훅, CSS 미디어 쿼리 감지에서 필요) Object.defineProperty(window, "matchMedia", { writable: true, value: vi.fn().mockImplementation((query) => ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), }); // scrollTo (스크롤 이동 로직에서 필요) window.scrollTo = vi.fn(); Element.prototype.scrollTo = vi.fn(); Element.prototype.scrollIntoView = vi.fn();

: 모든 것을 미리 추가하지 말고, 에러 메시지에서 "XXX is not defined"가 나올 때 해당 항목만 추가하세요.

TypeScript 타입 에러 방지: global.ResizeObserver에 할당할 때 타입 에러가 발생하면 파일 상단에 전역 타입을 선언하세요:

// setupTests.ts 최상단 declare global { var ResizeObserver: any; var IntersectionObserver: any; }

부록: Date/시간 Mocking

Date.now(), new Date()를 사용하는 코드는 테스트할 때마다 결과가 달라질 수 있습니다. Vitest의 fake timers로 시간을 고정할 수 있습니다.

현재 시간 고정하기

describe('시간 의존 로직', () => { beforeEach(() => { // 2024년 1월 15일 10:30:00으로 시간 고정 vi.useFakeTimers(); vi.setSystemTime(new Date('2024-01-15T10:30:00')); }); afterEach(() => { vi.useRealTimers(); // 반드시 복구! }); it('오늘 날짜가 표시된다', () => { render(<TodayDisplay />); expect(screen.getByText('2024년 1월 15일')).toBeInTheDocument(); }); it('D-Day 계산이 올바르다', () => { // 시간이 고정되어 있으므로 항상 같은 결과 const dDay = calculateDDay('2024-01-20'); expect(dDay).toBe(5); }); });

실전 예시: 만료 시간 체크

// 토큰 만료 체크 함수 function isTokenExpired(expiresAt: string): boolean { return new Date() > new Date(expiresAt); } describe("isTokenExpired", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2024-01-15T12:00:00")); }); afterEach(() => { vi.useRealTimers(); }); it("만료 시간이 지나면 true를 반환한다", () => { expect(isTokenExpired("2024-01-15T11:00:00")).toBe(true); }); it("만료 시간이 남아있으면 false를 반환한다", () => { expect(isTokenExpired("2024-01-15T13:00:00")).toBe(false); }); });

fake timers vs 시간 고정 - 언제 무엇을 사용?

| 상황 | 사용 방법 | | ---------------------------------- | ------------------------------------------- | | setTimeout, setInterval 테스트 | vi.advanceTimersByTime(ms) | | Date.now(), new Date() 고정 | vi.setSystemTime(date) | | 둘 다 필요 | vi.useFakeTimers() + vi.setSystemTime() |

// setTimeout + 현재 시간 모두 필요한 경우 vi.useFakeTimers(); vi.setSystemTime(new Date("2024-01-15")); // 3초 후 만료 체크하는 로직 테스트 act(() => { vi.advanceTimersByTime(3000); }); vi.useRealTimers();

부록: 테스트 실패 시 흔한 에러 5가지

주니어 개발자가 가장 많이 만나는 에러와 해결법입니다.

1. "Unable to find role=..." 에러

TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "확인"

원인: 요소가 없거나, role/name이 예상과 다름

해결법:

// 1. screen.debug()로 현재 DOM 확인 screen.debug(); // 2. logRoles()로 사용 가능한 role 확인 import { logRoles } from '@testing-library/dom'; const { container } = render(<MyComponent />); logRoles(container); // 3. 비동기 요소라면 findBy 사용 const button = await screen.findByRole('button', { name: '확인' });

2. "act(...)" 경고

Warning: An update to Component inside a test was not wrapped in act(...)

원인: 테스트 외부에서 상태 변경이 일어남

해결법:

// ❌ 상태 변경 후 바로 검증 result.current.increment(); expect(result.current.count).toBe(1); // ✅ act로 감싸기 import { act } from "@testing-library/react"; act(() => { result.current.increment(); }); expect(result.current.count).toBe(1); // ✅ 또는 userEvent 사용 (자동으로 act 처리) await user.click(button);

3. 비동기 테스트 타임아웃

Timed out in waitFor after 1000ms

원인: 요소가 나타나지 않거나, 조건이 충족되지 않음

해결법:

// 1. findBy vs waitFor 선택 확인 // findBy: 요소 출현 대기 (권장) const element = await screen.findByText('완료'); // waitFor: 복잡한 조건이나 요소 사라짐 대기 await waitFor(() => { expect(screen.queryByText('로딩')).not.toBeInTheDocument(); }); // 2. 타임아웃 늘리기 (임시 방편) await waitFor(() => { ... }, { timeout: 3000 }); // 3. Mock이 제대로 동작하는지 확인 console.log(vi.mocked(apiClient).mock.calls);

4. 테스트 간 상태 누수 (flaky 테스트)

첫 번째 실행: ✅ 통과
두 번째 실행: ❌ 실패

원인: 전역 상태, QueryClient 캐시, Mock이 정리되지 않음

해결법:

describe("MyComponent", () => { let queryClient: QueryClient; beforeEach(() => { // 매번 새 QueryClient 생성 queryClient = createTestQueryClient(); vi.clearAllMocks(); // Mock 호출 기록 초기화 }); afterEach(() => { queryClient.clear(); // 캐시 정리 vi.resetAllMocks(); // Mock 구현 초기화 }); });

5. fake timers + userEvent 충돌

TypeError: Cannot read property 'then' of undefined

원인: vi.useFakeTimers()가 userEvent 내부 타이머도 가짜로 만듦

해결법:

// 방법 1: userEvent에 advanceTimers 옵션 전달 const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime, }); // 방법 2: 타이머가 필요한 테스트만 따로 분리 describe("타이머 테스트", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); it("3초 후 사라진다", () => { // fake timers 사용 }); }); describe("일반 테스트", () => { it("버튼 클릭", async () => { // real timers 사용 (userEvent와 충돌 없음) }); });

마무리

이 프로젝트의 테스트 현황

✓ 9개 테스트 파일
✓ 91개 테스트 케이스
✓ 전체 통과
✓ 실행 시간: ~2초

테스트 학습 로드맵

1단계: 기초
├── describe, it, expect 이해
├── 기본 매처 사용
└── 첫 테스트 작성

2단계: 컴포넌트 테스트
├── render, screen 사용
├── 쿼리 메서드 이해
├── userEvent 사용
└── 비동기 테스트

3단계: 고급
├── Mock 이해 및 사용
├── 커스텀 훅 테스트
├── Context 테스트
└── Provider 래퍼 구성

4단계: 실전
├── 페이지 전체 테스트
├── 에러 케이스 테스트
├── 접근성 테스트
└── 테스트 유지보수

참고 자료

공식 문서:

추천 아티클:


"테스트는 버그를 잡는 것이 아니라, 버그가 생기지 않도록 막는 것이다."

처음에는 어렵고 시간이 오래 걸리지만, 익숙해지면 테스트 없이는 불안해서 코드를 못 짜게 됩니다.

오늘 작성한 테스트가 내일의 버그를 막습니다. 화이팅! 🚀

JP
이중표Frontend Engineer

3년차 프론트엔드 개발자. Next.js, React, TypeScript 기반 웹 애플리케이션 개발 전문. 대규모 트래픽 환경에서 SSR·ISR 렌더링 전략 설계 경험.

이력서 보기