Prologue: 100% 커버리지인데 버그가 터졌다
커버리지 리포트가 자랑스럽게 100%를 찍고 있었다. 근데 프로덕션에서 버그가 터졌다. 어떻게 된 걸까?
나중에 원인을 찾았다. 외부 API 호출을 모킹해뒀는데, 모킹이 실제 API 응답 형태와 달랐다. 커버리지는 "이 코드 라인이 실행됐는지"를 체크하는 거지, "이 코드가 실제 상황에서 올바르게 동작하는지"를 보장하지 않는다.
이 글은 그때의 교훈으로 시작한다. Vitest의 강력한 기능들을 제대로 쓰려면, 각 도구가 언제 써야 하고 언제 쓰면 안 되는지 알아야 한다.
모킹이란 뭔가: 배우 교체 비유
모킹(Mocking)을 이해하는 가장 좋은 비유는 영화 촬영이다.
실제 폭발 장면을 찍을 때 진짜 폭탄을 쓰면? 위험하고 비싸고 매번 반복할 수 없다. 그래서 특수효과 팀이 안전한 가짜 폭발을 만든다. 배우 입장에서는 진짜처럼 연기한다. 결과물도 진짜처럼 보인다.
테스트에서 모킹도 같다:
- 실제 DB 호출 → 느리고, 데이터가 바뀌고, 테스트가 불안정해짐
- 실제 외부 API → 네트워크 상황에 따라 실패, 비용 발생
- 모킹 → 빠르고, 예측 가능하고, 오프라인에서도 동작
Vitest에서 모킹하는 방법은 크게 세 가지다: vi.mock(), vi.spyOn(), vi.fn(). 각각 역할이 다르다.
vi.fn(): 가장 단순한 가짜 함수
vi.fn()은 그냥 빈 껍데기 함수다. 호출됐는지 확인하고, 반환값을 지정할 수 있다.
// 기본 사용법
const mockCallback = vi.fn()
mockCallback('hello')
expect(mockCallback).toHaveBeenCalledWith('hello')
expect(mockCallback).toHaveBeenCalledTimes(1)
반환값을 지정하고 싶으면 mockReturnValue 또는 mockResolvedValue를 쓴다:
const mockFetch = vi.fn()
// 동기 반환값
mockFetch.mockReturnValue({ status: 200, data: 'ok' })
// 비동기 반환값 (Promise)
mockFetch.mockResolvedValue({ status: 200, data: 'ok' })
// 첫 번째 호출만 특정값, 이후는 다른값
mockFetch
.mockResolvedValueOnce({ status: 200, data: 'first' })
.mockResolvedValueOnce({ status: 429, data: 'rate limited' })
.mockResolvedValue({ status: 500, data: 'error' })
vi.fn()은 주로 콜백이나 이벤트 핸들러 테스트에 쓴다:
// React 컴포넌트 테스트 예시
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'
test('버튼 클릭 시 onClick 핸들러 호출', async () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>클릭</Button>)
await userEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
vi.spyOn(): 진짜를 감시하는 스파이
vi.spyOn()은 실제 함수를 감시한다. 기본 동작은 유지하면서 호출 여부를 추적할 수 있다.
import * as utils from './utils'
test('formatDate가 올바르게 호출됨', () => {
const spy = vi.spyOn(utils, 'formatDate')
utils.formatDate(new Date('2026-01-01'))
expect(spy).toHaveBeenCalledTimes(1)
// 실제 formatDate 함수는 그대로 실행됨
})
필요하면 구현을 오버라이드할 수도 있다:
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// 테스트 중 console.error가 출력되지 않음
// 하지만 호출 여부는 추적됨
expect(consoleSpy).not.toHaveBeenCalled()
vi.fn() vs vi.spyOn() 비교표
| vi.fn() | vi.spyOn() | |
|---|---|---|
| 사용 대상 | 새로운 가짜 함수 생성 | 기존 객체의 메서드 감시 |
| 원본 유지 | N/A (새로 만듦) | 기본적으로 유지 |
| 복원 방법 | 필요없음 | spy.mockRestore() |
| 주 사용처 | 콜백, props | 모듈 함수, 전역 객체 |
vi.mock(): 모듈 전체를 교체하는 핵옵션
vi.mock()은 모듈 전체를 가짜로 교체한다. 가장 강력하지만 가장 조심해야 한다.
// api.ts
export async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`)
return response.json()
}
// UserProfile.test.ts
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { fetchUser } from './api'
import UserProfile from './UserProfile'
// 모듈 전체를 모킹
vi.mock('./api')
describe('UserProfile', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('유저 데이터를 불러와 표시한다', async () => {
const mockUser = { id: '1', name: '김개발', email: 'dev@example.com' }
vi.mocked(fetchUser).mockResolvedValue(mockUser)
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByText('김개발')).toBeInTheDocument()
})
})
it('에러 발생 시 에러 메시지를 표시한다', async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error('Network Error'))
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByText('데이터를 불러올 수 없습니다')).toBeInTheDocument()
})
})
})
vi.mock()의 호이스팅 문제
vi.mock()은 파일 최상단으로 호이스팅된다. 이게 처음 쓸 때 가장 헷갈리는 부분이다.
// 이렇게 작성해도...
import { fetchUser } from './api'
vi.mock('./api')
// 실제로는 이렇게 동작한다:
vi.mock('./api') // 먼저 실행됨
import { fetchUser } from './api' // 그 다음
그래서 팩토리 함수 안에서 외부 변수를 참조하면 에러가 난다:
// 잘못된 예시 - 에러 발생!
const mockData = { name: '김개발' }
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue(mockData) // mockData is not defined
}))
// 올바른 예시
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ name: '김개발' })
}))
동적으로 모킹값을 바꾸고 싶으면 팩토리에서 vi.fn()만 반환하고, 각 테스트에서 mockResolvedValue를 쓰는 게 패턴이다.
부분 모킹 (Partial Mock)
모듈의 일부만 모킹하고 싶을 때는 importActual을 쓴다:
vi.mock('./utils', async (importActual) => {
const actual = await importActual<typeof import('./utils')>()
return {
...actual,
// formatDate만 모킹, 나머지는 실제 구현 사용
formatDate: vi.fn().mockReturnValue('2026-01-01'),
}
})
스냅샷 테스트: 언제 쓰고 언제 피해야 하나
스냅샷 테스트는 컴포넌트의 렌더링 결과를 파일로 저장해두고, 다음 실행 때 달라지면 알려주는 방식이다.
import { render } from '@testing-library/react'
import UserCard from './UserCard'
test('UserCard 스냅샷', () => {
const { container } = render(
<UserCard name="김개발" role="Frontend Developer" />
)
expect(container).toMatchSnapshot()
})
첫 실행 때 __snapshots__/UserCard.test.tsx.snap 파일이 생성된다:
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserCard 스냅샷 1`] = `
<div>
<div
class="user-card"
>
<h2>
김개발
</h2>
<p>
Frontend Developer
</p>
</div>
</div>
`;
스냅샷 테스트의 좋은 사용처
- UI 회귀 방지: 의도치 않은 스타일/구조 변경 감지
- 복잡한 직렬화 결과: JSON 변환 결과 등
- 변경이 드문 컴포넌트: 디자인 시스템의 기본 컴포넌트
스냅샷 테스트의 함정
// 이런 스냅샷은 독이다
test('날짜 표시 스냅샷', () => {
const { container } = render(<PostDate date={new Date()} />)
expect(container).toMatchSnapshot()
// 매 실행마다 현재 시간이 달라서 항상 실패함!
})
// 이렇게 고쳐야 한다
test('날짜 표시 스냅샷', () => {
const fixedDate = new Date('2026-01-01T00:00:00.000Z')
const { container } = render(<PostDate date={fixedDate} />)
expect(container).toMatchSnapshot()
})
스냅샷 테스트 체크리스트
| 상황 | 스냅샷 적합? |
|---|---|
| 동적 데이터 (날짜, 랜덤) | 부적합 |
| 자주 바뀌는 UI | 부적합 (유지보수 지옥) |
| 외부 라이브러리 컴포넌트 | 부적합 (라이브러리 업데이트마다 실패) |
| 안정적인 기본 컴포넌트 | 적합 |
| 복잡한 데이터 변환 결과 | 적합 |
스냅샷을 업데이트하려면:
npx vitest run --update-snapshots
# 또는 단축키
npx vitest run -u
인라인 스냅샷
파일 대신 테스트 코드 안에 스냅샷을 저장하는 방법도 있다:
test('버튼 컴포넌트 인라인 스냅샷', () => {
const { container } = render(<Button variant="primary">확인</Button>)
expect(container).toMatchInlineSnapshot(`
<div>
<button
class="btn btn-primary"
type="button"
>
확인
</button>
</div>
`)
})
작은 컴포넌트에는 인라인 스냅샷이 더 가독성이 좋다.
커버리지: 숫자가 전부가 아니다
커버리지 설정 (vitest.config.ts)
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
coverage: {
provider: 'v8', // 또는 'istanbul'
reporter: ['text', 'lcov', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.d.ts',
'src/**/*.test.{ts,tsx}',
'src/**/*.stories.{ts,tsx}',
'src/types/**',
],
thresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
},
})
v8 vs istanbul
| v8 | istanbul | |
|---|---|---|
| 속도 | 빠름 | 느림 |
| 정확도 | 낮음 (소스맵 의존) | 높음 |
| 설정 | 별도 패키지 불필요 | @vitest/coverage-istanbul 필요 |
| TypeScript | 소스맵 기반 | 트랜스파일 후 분석 |
일반적으로 v8이 기본, 정밀한 분석이 필요하면 istanbul 권장.
커버리지 종류 이해하기
function getDiscount(price: number, userType: 'vip' | 'regular' | 'new') {
if (userType === 'vip') { // Branch 1: true
return price * 0.3 // Line covered
} else if (userType === 'new') { // Branch 2: true
return price * 0.1 // Line covered
} else { // Branch 3: else
return 0 // Line covered
}
}
// 이 테스트만 있으면:
test('vip 할인', () => {
expect(getDiscount(100, 'vip')).toBe(30)
})
// Lines: 66% (return 0 라인 미실행)
// Branches: 33% (vip 케이스만 테스트)
// Functions: 100% (함수는 호출됨)
함수 커버리지 100%가 의미없는 경우:
function riskyOperation(data: unknown) {
return (data as any).value.nested.property // 언제든 터질 수 있음
}
test('riskyOperation 호출', () => {
expect(riskyOperation({ value: { nested: { property: 42 } } })).toBe(42)
})
// Functions: 100%, Lines: 100% — 근데 null인 경우 폭발
의미있는 커버리지 전략
커버리지 80%를 목표로 하되, 어떤 80%인지가 중요하다.
- 비즈니스 로직 우선: 할인 계산, 검증 로직 → 100% 목표
- UI 컴포넌트: 60-70%도 괜찮음
- 유틸리티 함수: 엣지 케이스까지 → 90%+
- 외부 API 래퍼: 모킹이 많아 수치가 높아도 의미 없음
// vitest.config.ts — 파일별 커버리지 기준 다르게 설정
export default defineConfig({
test: {
coverage: {
thresholds: {
'src/lib/utils.ts': {
lines: 90,
branches: 85,
},
'src/components/**': {
lines: 70,
},
},
},
},
})
실전 패턴: React 컴포넌트 + API 호출 테스트
실제 프로젝트에서 자주 쓰는 패턴을 정리했다.
// hooks/useUserData.ts
import { useState, useEffect } from 'react'
import { fetchUser, updateUser } from '../api/users'
import type { User } from '../types'
export function useUserData(userId: string) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false))
}, [userId])
const update = async (data: Partial<User>) => {
const updated = await updateUser(userId, data)
setUser(updated)
return updated
}
return { user, loading, error, update }
}
// hooks/useUserData.test.ts
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useUserData } from './useUserData'
import * as usersApi from '../api/users'
vi.mock('../api/users')
const mockUser = {
id: '1',
name: '김개발',
email: 'dev@example.com',
}
describe('useUserData', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('유저 데이터 로딩 성공', async () => {
vi.mocked(usersApi.fetchUser).mockResolvedValue(mockUser)
const { result } = renderHook(() => useUserData('1'))
// 초기 상태: 로딩 중
expect(result.current.loading).toBe(true)
expect(result.current.user).toBeNull()
// 데이터 로드 완료 대기
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.user).toEqual(mockUser)
expect(result.current.error).toBeNull()
})
it('API 에러 처리', async () => {
const error = new Error('Network Error')
vi.mocked(usersApi.fetchUser).mockRejectedValue(error)
const { result } = renderHook(() => useUserData('1'))
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.user).toBeNull()
expect(result.current.error).toEqual(error)
})
it('유저 데이터 업데이트', async () => {
vi.mocked(usersApi.fetchUser).mockResolvedValue(mockUser)
const updatedUser = { ...mockUser, name: '박개발' }
vi.mocked(usersApi.updateUser).mockResolvedValue(updatedUser)
const { result } = renderHook(() => useUserData('1'))
await waitFor(() => expect(result.current.loading).toBe(false))
await result.current.update({ name: '박개발' })
expect(result.current.user).toEqual(updatedUser)
expect(usersApi.updateUser).toHaveBeenCalledWith('1', { name: '박개발' })
})
})
타이머 모킹: 시간을 내 손아귀에
setTimeout, setInterval, Date를 테스트할 때 실제로 기다릴 수 없다.
// debounce.ts
export function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): T {
let timer: ReturnType<typeof setTimeout>
return ((...args: unknown[]) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}) as T
}
// debounce.test.ts
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
import { debounce } from './debounce'
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('delay 이후에 함수가 실행된다', () => {
const fn = vi.fn()
const debouncedFn = debounce(fn, 300)
debouncedFn()
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(300)
expect(fn).toHaveBeenCalledTimes(1)
})
it('delay 내 여러 번 호출 시 마지막 한 번만 실행', () => {
const fn = vi.fn()
const debouncedFn = debounce(fn, 300)
debouncedFn()
debouncedFn()
debouncedFn()
vi.advanceTimersByTime(200)
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(1)
})
})
마무리: 모킹은 도구, 남용은 독
모킹을 너무 많이 쓰면 테스트가 구현 세부사항에 과도하게 의존한다. 리팩터링하면 테스트가 다 깨지는 상황이 생긴다.
올바른 모킹 전략 요약:
vi.fn()— 콜백, 이벤트 핸들러에 쓰고vi.spyOn()— 기존 구현을 감시하거나 부분 오버라이드할 때vi.mock()— 외부 의존성 (API, DB, 파일시스템) 격리할 때- 스냅샷 — 안정적인 UI 회귀 감지용, 동적 데이터엔 쓰지 마
- 커버리지 — 숫자보다 어떤 경로를 테스트했는지가 중요
테스트는 결국 자신감의 문제다. "이 코드 배포해도 되나?" 질문에 "응, 테스트 다 통과했으니까"라고 답할 수 있어야 진짜 테스트다. 커버리지 100%가 그 자신감을 주지 않는다면, 숫자는 그냥 숫자일 뿐이다.