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 설정을 마쳤다면, 실제 계산 로직 테스트 작성법도 살펴보세요.

시리즈 목차:

  1. Vitest와 MSW로 React Hook 테스트하기 (실전 가이드) ← 현재 글
  2. 계산 로직 테스트: 경계값과 엣지케이스 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의 기본 타임아웃도 확인하세요.