
Vitest 심화: 모킹, 스냅샷, 커버리지 전략
vi.mock()과 vi.spyOn()의 차이를 몰라 밤새 디버깅한 적이 있다. 스냅샷 테스트가 언제 독이 되고 약이 되는지, 커버리지 숫자가 왜 거짓말을 하는지 — 실전에서 배운 Vitest 심화 전략을 풀어본다.

vi.mock()과 vi.spyOn()의 차이를 몰라 밤새 디버깅한 적이 있다. 스냅샷 테스트가 언제 독이 되고 약이 되는지, 커버리지 숫자가 왜 거짓말을 하는지 — 실전에서 배운 Vitest 심화 전략을 풀어본다.
코드를 먼저 짜고 테스트하는 게 아닙니다. 테스트를 먼저 짜고, 그걸 통과하기 위해 코딩하는 것. 순서를 뒤집으면 버그가 사라집니다.

버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

코드 푸시하면 로봇이 테스트하고(CI), 로봇이 배포합니다(CD). '내 컴퓨터에서는 잘 됐는데'라는 변명은 이제 안 통합니다. 자동화 파이프라인으로 하루 100번 배포하기.

함수가 선언될 때의 렉시컬 환경(Lexical Environment)을 기억하는 현상. React Hooks의 원리이자 정보 은닉의 핵심 키.

커버리지 리포트가 자랑스럽게 100%를 찍고 있었다. 근데 프로덕션에서 버그가 터졌다. 어떻게 된 걸까?
나중에 원인을 찾았다. 외부 API 호출을 모킹해뒀는데, 모킹이 실제 API 응답 형태와 달랐다. 커버리지는 "이 코드 라인이 실행됐는지"를 체크하는 거지, "이 코드가 실제 상황에서 올바르게 동작하는지"를 보장하지 않는다.
이 글은 그때의 교훈으로 시작한다. Vitest의 강력한 기능들을 제대로 쓰려면, 각 도구가 언제 써야 하고 언제 쓰면 안 되는지 알아야 한다.
모킹(Mocking)을 이해하는 가장 좋은 비유는 영화 촬영이다.
실제 폭발 장면을 찍을 때 진짜 폭탄을 쓰면? 위험하고 비싸고 매번 반복할 수 없다. 그래서 특수효과 팀이 안전한 가짜 폭발을 만든다. 배우 입장에서는 진짜처럼 연기한다. 결과물도 진짜처럼 보인다.
테스트에서 모킹도 같다:
Vitest에서 모킹하는 방법은 크게 세 가지다: vi.mock(), vi.spyOn(), 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()은 실제 함수를 감시한다. 기본 동작은 유지하면서 호출 여부를 추적할 수 있다.
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()은 모듈 전체를 가짜로 교체한다. 가장 강력하지만 가장 조심해야 한다.
// 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()은 파일 최상단으로 호이스팅된다. 이게 처음 쓸 때 가장 헷갈리는 부분이다.
// 이렇게 작성해도...
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를 쓰는 게 패턴이다.
모듈의 일부만 모킹하고 싶을 때는 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>
`;
// 이런 스냅샷은 독이다
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
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 | 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인 경우 폭발
// vitest.config.ts — 파일별 커버리지 기준 다르게 설정
export default defineConfig({
test: {
coverage: {
thresholds: {
'src/lib/utils.ts': {
lines: 90,
branches: 85,
},
'src/components/**': {
lines: 70,
},
},
},
},
})
실제 프로젝트에서 자주 쓰는 패턴을 정리했다.
// 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, 파일시스템) 격리할 때테스트는 결국 자신감의 문제다. "이 코드 배포해도 되나?" 질문에 "응, 테스트 다 통과했으니까"라고 답할 수 있어야 진짜 테스트다. 커버리지 100%가 그 자신감을 주지 않는다면, 숫자는 그냥 숫자일 뿐이다.