TechFeedTechFeed
Frontend

Vitest 실전 튜토리얼 — React 단위 테스트부터 컴포넌트 테스트, GitHub Actions CI 연동까지

Vite 기반 프로젝트에서 Vitest로 단위 테스트를 처음 도입하는 완전 가이드. 설치 설정부터 커스텀 훅 테스트, vi.mock API 모킹, 커버리지 임계값 설정, GitHub Actions CI 연동까지 코드 예제 중심으로 단계별로 설명한다.

한 줄 요약: Vitest는 Vite 기반 프로젝트에서 Jest 대비 5~20배 빠른 단위 테스트 환경을 제공한다. 설치 1분, 첫 테스트 작성 5분이면 바로 시작할 수 있다.

이 글이 필요한 사람
  • Vite/React/Vue 프로젝트에서 테스트를 처음 도입하는 개발자
  • Jest가 너무 느려서 대안을 찾고 있는 팀
  • 단위 테스트, 통합 테스트, GitHub Actions CI까지 한 번에 세팅하고 싶은 개발자

※ 2026년 4월 기준, Vitest 3.x 기준으로 작성. 공식 문서: vitest.dev

Jest 대신 Vitest를 선택하는 이유

2022년 등장한 Vitest는 Vite의 변환 파이프라인을 그대로 재사용한다. Jest가 Babel로 트랜스파일하는 반면, Vitest는 esbuild + Vite를 쓰기 때문에 첫 실행 속도가 다르다. 실제 비교 수치를 보면 차이가 명확하다.

Jest API와 100% 호환된다는 점이 핵심이다. describe, it, expect, vi.mock 모두 같은 방식으로 쓴다. 기존 Jest 테스트 코드를 그대로 마이그레이션할 수 있다.

설치와 기본 설정 — 5분 완성

Vite 프로젝트가 이미 있다면 vitest 하나만 설치하면 된다. React Testing Library와 jsdom까지 함께 설치한다.

패키지 설치 (npm)
# Vitest + 필수 의존성 설치 npm install -D vitest @vitest/ui jsdom npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event # TypeScript 사용 시 추가 npm install -D @types/node

설치 후 vite.config.ts에 테스트 설정을 추가한다. 별도 vitest.config.ts를 만들어도 되지만, 대부분의 경우 vite 설정과 합치는 게 관리하기 편하다.

vite.config.ts — test 블록 추가
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], test: { // 브라우저 환경 시뮬레이션 environment: 'jsdom', // jest-dom matchers 자동 import setupFiles: ['./src/test/setup.ts'], // 전역 API (describe, it, expect) 사용 globals: true, // 커버리지 설정 coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'], exclude: ['node_modules/', 'src/test/'] } } })

setupFiles에 지정한 파일을 만든다. jest-dom의 커스텀 matchers(toBeInTheDocument 등)를 자동으로 불러온다.

src/test/setup.ts
import '@testing-library/jest-dom'

마지막으로 package.json에 스크립트를 추가한다.

package.json scripts
{ "scripts": { "test": "vitest", "test:ui": "vitest --ui", "test:run": "vitest run", "test:coverage": "vitest run --coverage" } }
globals: true 설정 주의
globals: true를 설정하면 describe, it, expect를 import 없이 사용할 수 있다. TypeScript 환경에서는 tsconfig.json"types": ["vitest/globals"]도 추가해야 타입 에러가 없다.
Vitest UI 대시보드 — 테스트 결과를 브라우저에서 확인하는 화면
vitest --ui 실행 시 브라우저 기반 테스트 대시보드가 열린다

단위 테스트 작성 — 함수와 커스텀 훅

순수 함수 테스트부터 시작한다. 유틸리티 함수에 대한 테스트는 가장 작성하기 쉽고, 빠르게 신뢰성을 높이는 방법이다.

src/utils/format.ts — 테스트 대상 함수
// 숫자를 한국 원화 형식으로 포맷 export function formatKRW(amount: number): string { return new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(amount) } // 날짜를 'YYYY.MM.DD' 형식으로 변환 export function formatDate(date: Date): string { const y = date.getFullYear() const m = String(date.getMonth() + 1).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0') return `${y}.${m}.${d}` } // 문자열을 slug로 변환 export function toSlug(text: string): string { return text .toLowerCase() .trim() .replace(/[^a-z0-9가-힣\s-]/g, '') .replace(/\s+/g, '-') }
src/utils/format.test.ts — 단위 테스트
import { describe, it, expect } from 'vitest' import { formatKRW, formatDate, toSlug } from './format' describe('formatKRW', () => { it('양수를 원화 형식으로 변환한다', () => { expect(formatKRW(1000)).toBe('₩1,000') }) it('0을 처리한다', () => { expect(formatKRW(0)).toBe('₩0') }) it('음수를 처리한다', () => { expect(formatKRW(-500)).toContain('500') }) }) describe('formatDate', () => { it('날짜를 YYYY.MM.DD 형식으로 변환한다', () => { const date = new Date('2026-04-08') expect(formatDate(date)).toBe('2026.04.08') }) it('한 자리 월/일을 두 자리로 패딩한다', () => { const date = new Date('2026-01-05') expect(formatDate(date)).toBe('2026.01.05') }) }) describe('toSlug', () => { it('공백을 하이픈으로 변환한다', () => { expect(toSlug('hello world')).toBe('hello-world') }) it('대문자를 소문자로 변환한다', () => { expect(toSlug('Hello World')).toBe('hello-world') }) it('특수문자를 제거한다', () => { expect(toSlug('hello! @world')).toBe('hello-world') }) })

커스텀 훅은 @testing-library/reactrenderHook으로 테스트한다. 훅이 반환하는 상태와 함수를 직접 검증할 수 있다.

src/hooks/useCounter.test.ts — 커스텀 훅 테스트
import { renderHook, act } from '@testing-library/react' import { describe, it, expect } from 'vitest' import { useCounter } from './useCounter' describe('useCounter', () => { it('초기값이 0이다', () => { const { result } = renderHook(() => useCounter()) expect(result.current.count).toBe(0) }) it('increment 호출 시 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) }) it('reset 호출 시 초기값으로 돌아간다', () => { const { result } = renderHook(() => useCounter(5)) act(() => { result.current.increment() result.current.reset() }) expect(result.current.count).toBe(5) }) })

React 컴포넌트 테스트 — render, user-event, queries

컴포넌트 테스트는 렌더링 결과를 DOM 기준으로 검증한다. @testing-library/react의 queries (getByRole, getByText 등)를 쓰면 실제 사용자 관점에서 테스트할 수 있다.

src/components/LoginForm.test.tsx — 컴포넌트 테스트
import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, it, expect, vi } from 'vitest' import { LoginForm } from './LoginForm' describe('LoginForm', () => { it('이메일, 비밀번호 입력 필드와 로그인 버튼이 렌더된다', () => { render(<LoginForm onSubmit={vi.fn()} />) expect(screen.getByRole('textbox', { name: /이메일/i })).toBeInTheDocument() expect(screen.getByLabelText(/비밀번호/i)).toBeInTheDocument() expect(screen.getByRole('button', { name: /로그인/i })).toBeInTheDocument() }) it('빈 폼 제출 시 유효성 에러가 표시된다', async () => { const user = userEvent.setup() render(<LoginForm onSubmit={vi.fn()} />) await user.click(screen.getByRole('button', { name: /로그인/i })) expect(await screen.findByText(/이메일을 입력해주세요/i)).toBeInTheDocument() }) it('올바른 값 입력 후 제출 시 onSubmit이 호출된다', async () => { const user = userEvent.setup() const onSubmit = vi.fn() render(<LoginForm onSubmit={onSubmit} />) await user.type( screen.getByRole('textbox', { name: /이메일/i }), 'test@example.com' ) await user.type(screen.getByLabelText(/비밀번호/i), 'password123') await user.click(screen.getByRole('button', { name: /로그인/i })) await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123' }) }) }) })
getByRole vs getByTestId — 무엇을 써야 하나?
Testing Library 철학은 사용자가 실제로 인식하는 방식으로 쿼리하는 것이다. 우선순위: getByRolegetByLabelTextgetByPlaceholderTextgetByTextgetByTestId. getByTestId는 마지막 수단이다.
Vitest 테스트 실행 결과 터미널 출력 — 통과/실패 케이스 표시
npm run test 실행 시 각 테스트 케이스의 통과/실패를 컬러로 확인할 수 있다

vi.mock으로 API 호출과 모듈 모킹하기

실제 API를 호출하면 테스트가 외부 상태에 의존하게 된다. vi.mock으로 모듈을 가로채거나, vi.fn()으로 함수를 mock해서 순수한 단위 테스트를 유지한다.

API 모듈 모킹 예시
import { render, screen, waitFor } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' import { UserProfile } from './UserProfile' // api 모듈 전체를 mock vi.mock('../api/user', () => ({ fetchUser: vi.fn() })) // mock된 모듈 import import { fetchUser } from '../api/user' describe('UserProfile', () => { beforeEach(() => { vi.clearAllMocks() }) it('유저 정보를 불러와서 렌더한다', async () => { // mock 반환값 설정 vi.mocked(fetchUser).mockResolvedValue({ id: 1, name: '김개발', email: 'dev@example.com' }) render(<UserProfile userId={1} />) // 로딩 상태 expect(screen.getByText(/불러오는 중/i)).toBeInTheDocument() // 데이터 로드 완료 await waitFor(() => { expect(screen.getByText('김개발')).toBeInTheDocument() expect(screen.getByText('dev@example.com')).toBeInTheDocument() }) expect(fetchUser).toHaveBeenCalledWith(1) }) it('API 에러 시 에러 메시지를 표시한다', async () => { vi.mocked(fetchUser).mockRejectedValue(new Error('Network Error')) render(<UserProfile userId={1} />) await waitFor(() => { expect(screen.getByText(/데이터를 불러올 수 없습니다/i)).toBeInTheDocument() }) }) })

날짜, 타이머, 랜덤 값처럼 비결정론적 값은 vi.setSystemTime이나 vi.useFakeTimers로 고정한다.

타이머 mock — vi.useFakeTimers
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { debounce } from './debounce' describe('debounce', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('지정한 시간 이후에 한 번만 실행된다', () => { const fn = vi.fn() const debounced = debounce(fn, 300) debounced() debounced() debounced() // 300ms 전 — 아직 실행 안 됨 expect(fn).not.toHaveBeenCalled() // 300ms 경과 vi.advanceTimersByTime(300) expect(fn).toHaveBeenCalledTimes(1) }) })

커버리지 측정과 임계값 설정

커버리지는 어떤 코드가 테스트되고 있는지 확인하는 지표다. --coverage 플래그로 실행하면 HTML 리포트를 생성하고, CI에서 임계값 미달 시 빌드를 실패시킬 수 있다.

vite.config.ts — 커버리지 임계값 설정
export default defineConfig({ test: { coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'], // 임계값 미달 시 테스트 실패로 처리 thresholds: { lines: 80, functions: 80, branches: 70, statements: 80 }, // 커버리지에서 제외할 파일 exclude: [ 'node_modules/', 'src/test/', '**/*.d.ts', '**/*.config.*', '**/index.ts' // re-export 파일 ], // 커버리지 측정 대상 include: ['src/**/*.{ts,tsx}'] } } })

커버리지 리포트를 실행하면 coverage/index.html이 생성된다. 브라우저에서 열면 파일별 라인 커버리지를 색상으로 확인할 수 있다.

커버리지 실행 및 확인
# 커버리지 포함 테스트 실행 npm run test:coverage # 결과 예시 # ----------|---------|----------|---------|----------| # File | % Stmts | % Branch | % Funcs | % Lines | # ----------|---------|----------|---------|----------| # All files | 87.5 | 82.35 | 90.0 | 87.5 | # utils/ | 92.0 | 85.0 | 95.0 | 92.0 | # hooks/ | 85.0 | 80.0 | 88.0 | 85.0 | # ----------|---------|----------|---------|----------| # HTML 리포트 열기 (macOS) open coverage/index.html
Vitest 커버리지 HTML 리포트 — 파일별 커버리지 퍼센트와 미커버 라인 표시
커버리지 HTML 리포트에서 빨간색(미커버)과 초록색(커버됨) 라인을 파일별로 확인할 수 있다

GitHub Actions CI 통합 — PR마다 테스트 자동 실행

PR을 열 때마다 테스트가 자동 실행되도록 GitHub Actions 워크플로우를 설정한다. 테스트 실패 시 머지를 차단하는 브랜치 보호 규칙과 함께 쓰면 코드 품질을 유지할 수 있다.

.github/workflows/test.yml
name: Test on: push: branches: [main, develop] pull_request: branches: [main, develop] jobs: test: runs-on: ubuntu-latest strategy: matrix: node-version: [20.x, 22.x] steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies run: npm ci - name: Run tests run: npm run test:run - name: Run coverage run: npm run test:coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: files: ./coverage/lcov.info fail_ci_if_error: false
브랜치 보호 규칙 설정
GitHub 리포지토리 Settings → Branches → Branch protection rules에서 Require status checks to pass before merging를 활성화하고 test (20.x)를 필수 체크로 지정하면 테스트 실패 PR을 머지할 수 없다.

자주 막히는 케이스와 해결법

Vitest 세팅 시 자주 만나는 에러와 해결 방법을 정리했다. 대부분 환경 설정 미스로 발생한다.

ReferenceError: document is not defined
vite.config.tstest.environment가 기본값 'node'로 설정돼 있을 때 발생한다. DOM 조작이 필요한 컴포넌트 테스트는 environment: 'jsdom'으로 변경해야 한다. 특정 테스트 파일만 jsdom을 쓰고 싶으면 파일 상단에 // @vitest-environment jsdom 주석을 추가한다.
Cannot find module '@testing-library/jest-dom'
setupFiles에 지정한 경로가 잘못됐거나 패키지가 설치되지 않은 경우다. npm install -D @testing-library/jest-dom으로 재설치 후, setupFiles 경로가 실제 파일 위치와 일치하는지 확인한다.
vi.mock 호이스팅 에러 — "Cannot access before initialization"
vi.mock은 파일 최상단으로 자동 호이스팅된다. mock 블록 안에서 같은 파일의 변수를 참조하면 이 에러가 발생한다. 해결: mock 팩토리 함수 내에서 직접 값을 선언하거나, vi.hoisted()를 사용해 변수를 호이스팅에 포함시킨다.
TypeScript 에러: Property 'toBeInTheDocument' does not exist
tsconfig.jsoncompilerOptions.types"@testing-library/jest-dom"을 추가한다. 또는 setupFiles에서 import 후 /// <reference types="@testing-library/jest-dom" /> 타입 선언을 추가한다.

실전에서 바로 쓰는 테스트 작성 원칙

테스트를 오래 유지 보수할 팀이라면 처음부터 이 원칙을 지키는 게 나중에 리팩토링 비용을 줄인다.

VitestReact Testing Library단위 테스트컴포넌트 테스트vi.mock커버리지GitHub ActionsJestTDD프론트엔드 테스팅

관련 포스트

Playwright E2E 테스트 자동화 실전 가이드 — 설치부터 CI/CD 통합까지2026-04-03React Compiler 1.0 실전 마이그레이션 가이드 — useMemo·useCallback 없는 React 개발2026-04-24pnpm vs npm vs Yarn Berry 2026 — JavaScript 패키지 매니저 속도·디스크·워크스페이스 실전 비교2026-04-21Vercel AI SDK 6 완전 가이드 — 에이전트 1급 추상화, MCP 풀 지원, DevTools2026-04-17