Vitest와 MSW로 React Hook 테스트하기 (실전 가이드)
Jest 테스트가 느려서 Vitest로 마이그레이션했습니다. MSW와 함께 Custom Hook을 테스트하는 실전 가이드를 공유합니다.
1. 왜 Vitest인가?
1.1 Jest의 한계
기존 Jest 환경에서 겪었던 문제들:
❌ 느린 테스트 실행 속도
❌ ESM 모듈 지원 복잡함
❌ TypeScript 설정 별도 필요 (ts-jest)
❌ Vite 프로젝트와 설정 중복
1.2 Vitest 장점
✅ Vite 기반으로 빠른 실행 속도 (HMR 활용)
✅ ESM 네이티브 지원
✅ TypeScript 기본 지원
✅ Jest API 호환 (거의 동일한 문법)
✅ vite.config.ts 재사용 가능
1.3 성능 비교 (218개 테스트 기준)
| 지표 | Jest | Vitest | 개선 |
|---|---|---|---|
| Cold Start | 8.5초 | 2.3초 | -73% |
| Watch Mode 재실행 | 3.2초 | 0.8초 | -75% |
| 메모리 사용 | 높음 | 낮음 | 개선 |
2. Vitest 설정
2.1 패키지 설치
pnpm add -D vitest @vitejs/plugin-react jsdom \
@testing-library/react @testing-library/jest-dom \
msw
2.2 vitest.config.ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
// 환경 설정
environment: 'jsdom',
// 전역 설정 (describe, it, expect 등)
globals: true,
// 셋업 파일
setupFiles: ['./src/__tests__/setup.ts'],
// 테스트 파일 패턴
include: ['src/**/*.{test,spec}.{ts,tsx}'],
// 제외 패턴
exclude: ['node_modules', '.next'],
// 커버리지 설정
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: [
'src/lib/**/*.ts',
'src/hooks/**/*.ts',
'src/actions/**/*.ts',
],
exclude: ['src/**/*.d.ts', 'src/**/index.ts'],
},
// 타임아웃 (기본 10초)
testTimeout: 10000,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
2.3 셋업 파일
// src/__tests__/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
// 각 테스트 후 정리
afterEach(() => {
cleanup();
});
// fetch 모킹 준비
global.fetch = vi.fn();
// structuredClone 폴리필 (Node.js 16 이하용)
if (typeof structuredClone === 'undefined') {
global.structuredClone = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));
}
2.4 package.json 스크립트
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
3. MSW로 API 모킹
3.1 MSW란?
Mock Service Worker는 서비스 워커를 활용하여 네트워크 요청을 가로채는 API 모킹 라이브러리입니다.
장점:
✅ 실제 fetch/axios 호출을 가로챔 (코드 수정 불필요)
✅ 브라우저/Node.js 모두 지원
✅ 요청/응답 검증 가능
✅ 네트워크 지연, 에러 시뮬레이션 가능
3.2 핸들러 정의
// src/__tests__/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
// Mock 데이터
const mockItems = [
{ id: 'item-1', name: '항목A', status: 'ACTIVE' },
{ id: 'item-2', name: '항목B', status: 'ACTIVE' },
];
export const handlers = [
// 목록 조회
http.get('/api/items', ({ request }) => {
const url = new URL(request.url);
const status = url.searchParams.get('status');
let filtered = [...mockItems];
if (status && status !== 'ALL') {
filtered = filtered.filter((item) => item.status === status);
}
return HttpResponse.json({
items: filtered,
total: filtered.length,
});
}),
// 생성
http.post('/api/items', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({
item: { id: `item-${Date.now()}`, ...body },
}, { status: 201 });
}),
// 수정
http.patch('/api/items/:id', async ({ request, params }) => {
const { id } = params;
const body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({
item: { id, ...body },
});
}),
// 삭제
http.delete('/api/items/:id', () => {
return HttpResponse.json({ success: true });
}),
];
3.3 에러 응답 시뮬레이션
// 에러 핸들러
export const errorHandlers = [
http.post('/api/items', () => {
return HttpResponse.json(
{ error: '서버 오류가 발생했습니다.' },
{ status: 500 }
);
}),
http.delete('/api/items/:id', () => {
return HttpResponse.json(
{ error: '삭제 권한이 없습니다.' },
{ status: 403 }
);
}),
];
3.4 네트워크 지연 시뮬레이션
http.post('/api/items', async ({ request }) => {
// 500ms 지연
await delay(500);
const body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ item: { id: 'new-1', ...body } });
});
4. Custom Hook 테스트
4.1 renderHook 사용법
import { renderHook, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('useOptimisticData', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('초기 상태를 올바르게 반환해야 함', () => {
const { result } = renderHook(() => useOptimisticData(initialData));
expect(result.current.data).toEqual(initialData);
expect(result.current.pendingCells).toEqual(new Set());
expect(result.current.isPending).toBe(false);
});
});
4.2 비동기 Hook 테스트
it('낙관적 업데이트 후 API 성공 시 상태 유지', async () => {
// Mock fetch 성공 응답
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ record: { id: 'rec-1' } }),
} as Response);
const { result } = renderHook(() => useOptimisticData(initialData));
// 낙관적 업데이트 실행
await act(async () => {
await result.current.optimisticUpdate('item-1', '2026-01-01', 'COMPLETE');
});
// 결과 검증
await waitFor(() => {
expect(result.current.data['item-1']['2026-01-01'].statusType).toBe('COMPLETE');
});
});
4.3 에러 처리 테스트
it('API 실패 시 롤백되어야 함', async () => {
// Mock fetch 실패 응답
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: false,
status: 500,
} as Response);
const { result } = renderHook(() => useOptimisticData(initialData));
// 원본 상태 저장
const originalValue = result.current.data['item-1']['2026-01-01']?.statusType;
// 낙관적 업데이트 실행 (실패 예상)
await act(async () => {
await result.current.optimisticUpdate('item-1', '2026-01-01', 'COMPLETE');
});
// 롤백 검증
await waitFor(() => {
expect(result.current.data['item-1']['2026-01-01']?.statusType).toBe(originalValue);
});
});
4.4 Race Condition 테스트
it('동시에 여러 셀 편집 시 독립적으로 롤백되어야 함', async () => {
// 첫 번째 요청: 지연 후 실패
vi.mocked(global.fetch)
.mockImplementationOnce(async () => {
await new Promise((r) => setTimeout(r, 100));
return { ok: false, status: 500 } as Response;
})
// 두 번째 요청: 즉시 성공
.mockResolvedValueOnce({
ok: true,
json: async () => ({ record: { id: 'rec-2' } }),
} as Response);
const { result } = renderHook(() => useOptimisticData(initialData));
// 두 셀 동시 업데이트
await act(async () => {
// 첫 번째 셀 (실패 예정)
result.current.optimisticUpdate('item-1', '2026-01-01', 'COMPLETE');
// 두 번째 셀 (성공 예정)
result.current.optimisticUpdate('item-2', '2026-01-01', 'PENDING');
});
// 결과 대기
await waitFor(() => {
// 첫 번째 셀: 롤백됨
expect(result.current.data['item-1']['2026-01-01']?.statusType).toBeNull();
// 두 번째 셀: 성공 유지
expect(result.current.data['item-2']['2026-01-01'].statusType).toBe('PENDING');
});
});
5. 유틸 함수 테스트
5.1 순수 함수 테스트
// src/lib/core/password.test.ts
import { describe, it, expect } from 'vitest';
import { analyzePassword, isStrongPassword } from './password';
describe('analyzePassword', () => {
it('빈 문자열은 모든 조건 실패', () => {
const result = analyzePassword('');
expect(result.hasMinLength).toBe(false);
expect(result.hasUpperCase).toBe(false);
expect(result.hasLowerCase).toBe(false);
expect(result.hasNumber).toBe(false);
expect(result.hasSpecialChar).toBe(false);
expect(result.strength).toBe('weak');
});
it('강력한 비밀번호 분석', () => {
const result = analyzePassword('SecurePass123!');
expect(result.hasMinLength).toBe(true);
expect(result.hasUpperCase).toBe(true);
expect(result.hasLowerCase).toBe(true);
expect(result.hasNumber).toBe(true);
expect(result.hasSpecialChar).toBe(true);
expect(result.strength).toBe('strong');
});
});
describe('isStrongPassword', () => {
it.each([
['SecurePass123!', true],
['weakpass', false],
['12345678', false],
['', false],
])('isStrongPassword(%s) = %s', (password, expected) => {
expect(isStrongPassword(password)).toBe(expected);
});
});
5.2 날짜 유틸 테스트
// src/lib/core/date-utils.test.ts
import { describe, it, expect } from 'vitest';
import { formatLocalDate, parseLocalDate, getLocalMonthDates } from './date-utils';
describe('formatLocalDate', () => {
it('Date 객체를 YYYY-MM-DD 형식으로 변환', () => {
const date = new Date(2026, 0, 15); // 2026년 1월 15일
expect(formatLocalDate(date)).toBe('2026-01-15');
});
it('타임존 영향 없이 로컬 날짜 유지', () => {
// UTC 기준 전날로 표시될 수 있는 시간
const date = new Date('2026-01-15T00:30:00+09:00');
expect(formatLocalDate(date)).toBe('2026-01-15');
});
});
describe('getLocalMonthDates', () => {
it('해당 월의 모든 날짜 배열 반환', () => {
const dates = getLocalMonthDates(2026, 1);
expect(dates.length).toBe(31);
expect(dates[0]).toBe('2026-01-01');
expect(dates[30]).toBe('2026-01-31');
});
it('윤년 2월 처리', () => {
const dates = getLocalMonthDates(2024, 2);
expect(dates.length).toBe(29);
});
});
6. 테스트 유틸리티
6.1 테스트용 래퍼 컴포넌트
// src/__tests__/utils/test-utils.tsx
import { ReactNode } from 'react';
import { SessionProvider } from 'next-auth/react';
interface WrapperProps {
children: ReactNode;
}
// 인증이 필요한 컴포넌트 테스트용
export function AuthWrapper({ children }: WrapperProps) {
return (
<SessionProvider
session={{
user: { id: 'user-1', name: 'Test User', email: '[email protected]', role: 'ADMIN' },
expires: '2099-12-31',
}}
>
{children}
</SessionProvider>
);
}
// 커스텀 renderHook
export function renderHookWithAuth<T>(hook: () => T) {
return renderHook(hook, { wrapper: AuthWrapper });
}
6.2 Mock 데이터 팩토리
// src/__tests__/utils/factories.ts
import type { Item, Record } from '@/types';
export function createMockItem(overrides: Partial<Item> = {}): Item {
return {
id: `item-${Math.random().toString(36).slice(2)}`,
name: '테스트 항목',
status: 'ACTIVE',
groupId: 'group-1',
...overrides,
};
}
export function createMockRecord(overrides: Partial<Record> = {}): Record {
return {
id: `rec-${Math.random().toString(36).slice(2)}`,
itemId: 'item-1',
date: '2026-01-15',
statusType: null,
startTime: null,
endTime: null,
...overrides,
};
}
7. 핵심 개념 정리
7.1 Vitest vs Jest 문법 비교
| 기능 | Jest | Vitest |
|---|---|---|
| Import | 자동 전역 | import { describe, it, expect } from 'vitest' (globals: true 시 불필요) |
| Mocking | jest.fn() |
vi.fn() |
| Timer Mock | jest.useFakeTimers() |
vi.useFakeTimers() |
| Module Mock | jest.mock() |
vi.mock() |
| Spy | jest.spyOn() |
vi.spyOn() |
7.2 테스트 구조 패턴
describe('기능/모듈명', () => {
// 공통 설정
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe('세부 기능', () => {
it('특정 조건에서 예상 동작', () => {
// Arrange (준비)
const input = createMockData();
// Act (실행)
const result = doSomething(input);
// Assert (검증)
expect(result).toBe(expected);
});
});
});
7.3 비동기 테스트 패턴
1. act() - 상태 변경을 발생시키는 코드 래핑
2. waitFor() - 조건이 충족될 때까지 대기
3. findBy* - waitFor를 내장한 쿼리 (DOM 테스트)
8. 베스트 프랙티스
8.1 테스트 체크리스트
□ 각 테스트는 독립적인가? (다른 테스트에 의존하지 않음)
□ 테스트 설명이 명확한가? (무엇을 테스트하는지 알 수 있음)
□ 에러 케이스도 테스트하는가?
□ Mock을 사용한 경우 afterEach에서 초기화하는가?
□ 비동기 테스트에 적절한 타임아웃이 있는가?
8.2 파일 구조
src/
├── __tests__/
│ ├── setup.ts # 전역 설정
│ ├── mocks/
│ │ └── handlers.ts # MSW 핸들러
│ └── utils/
│ ├── test-utils.tsx # 테스트 유틸
│ └── factories.ts # Mock 데이터 팩토리
├── lib/
│ └── core/
│ ├── password.ts
│ └── password.test.ts # 같은 폴더에 테스트 파일
└── hooks/
├── useOptimisticData.ts
└── useOptimisticData.test.tsx
8.3 커버리지 목표
권장 커버리지 (실용적 기준):
- 유틸 함수: 90%+
- Custom Hooks: 80%+
- 컴포넌트: 70%+
- 전체: 80%+
9. 참고 자료
10. 다음 단계
Vitest와 MSW 설정을 마쳤다면, 실제 계산 로직 테스트 작성법도 살펴보세요.
시리즈 목차:
- Vitest와 MSW로 React Hook 테스트하기 (실전 가이드) ← 현재 글
- 계산 로직 테스트: 경계값과 엣지케이스 47개로 커버하기
11. FAQ (자주 묻는 질문)
Q: Jest에서 Vitest로 마이그레이션하기 어렵나요?
A: 문법이 거의 동일하여 쉽습니다. jest.fn()을 vi.fn()으로, jest.mock()을 vi.mock()으로 변경하면 대부분 작동합니다. 설정 파일만 vitest.config.ts로 새로 작성하면 됩니다.
Q: MSW v1에서 v2로 마이그레이션할 때 주의점은?
A: v2에서는 rest.get()이 http.get()으로, res(ctx.json())이 HttpResponse.json()으로 변경되었습니다. 공식 마이그레이션 가이드를 참고하세요.
Q: renderHook에서 Provider가 필요한 경우 어떻게 하나요?
A: wrapper 옵션을 사용합니다. renderHook(() => useMyHook(), { wrapper: MyProvider })처럼 작성하면 됩니다.
Q: 비동기 테스트가 타임아웃되는 경우 어떻게 하나요?
A: testTimeout을 늘리거나, 개별 테스트에 { timeout: 15000 }을 설정합니다. 또한 waitFor의 기본 타임아웃도 확인하세요.