Testing Library + Vitest: 프론트엔드 단위 테스트 완전 가이드

프론트엔드

VitestTesting Library단위 테스트MSWReact

이 글은 누구를 위한 것인가

  • Jest에서 Vitest로 전환하고 싶은 팀
  • Testing Library의 올바른 쿼리 선택 기준을 이해하려는 개발자
  • MSW로 API를 목킹해서 통합 테스트를 작성하려는 팀

들어가며

"테스트는 구현 세부사항이 아닌 동작을 테스트해야 한다." Testing Library의 철학이다. getByRole, getByLabelText는 사용자 관점에서 요소를 찾고, 접근성도 자동으로 검증한다.

이 글은 bluefoxdev.kr의 프론트엔드 테스트 완전 가이드 를 참고하여 작성했습니다.


1. Vitest vs Jest 비교

[Vitest 장점]
  Vite 기반: ESM 네이티브, TypeScript 즉시 지원
  빠름: 병렬 실행, HMR 테스트 워치
  Jest 호환: 대부분의 Jest API 동일
  설정: vite.config.ts에 통합 (별도 파일 불필요)

[테스트 우선순위]
  유닛: 개별 함수, 훅
  컴포넌트: 렌더링, 인터랙션
  통합: 여러 컴포넌트 + API 조합
  E2E: Playwright (별도)

[Testing Library 쿼리 우선순위]
  1. getByRole: 가장 권장 (접근성 기반)
  2. getByLabelText: 폼 요소
  3. getByPlaceholderText: 플레이스홀더
  4. getByText: 텍스트 내용
  5. getByDisplayValue: 현재 값
  6. getByAltText: 이미지 alt
  7. getByTitle: title 속성
  8. getByTestId: 마지막 수단 (data-testid)

[쿼리 접미사]
  getBy*: 없으면 에러
  queryBy*: 없으면 null (조건부 검사)
  findBy*: 비동기 (Promise 반환)
  getAllBy*: 배열 반환

2. Vitest + Testing Library 구현

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,          // describe, it, expect 전역 사용
    environment: 'jsdom',   // DOM 환경
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov', 'html'],
      exclude: ['node_modules', 'src/test', '**/*.stories.tsx'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 70,
      },
    },
  },
});
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, afterAll } from 'vitest';
import { server } from './mocks/server';  // MSW 서버

// MSW 서버 시작/종료
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
  cleanup();
  server.resetHandlers();
});
afterAll(() => server.close());
// 컴포넌트 테스트 - 올바른 쿼리 사용
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  // 각 테스트마다 userEvent 초기화
  const user = userEvent.setup();
  
  it('폼이 올바르게 렌더링된다', () => {
    render(<LoginForm onSubmit={() => {}} />);
    
    // getByRole: 접근성 기반 (권장)
    expect(screen.getByRole('textbox', { name: '이메일' })).toBeInTheDocument();
    expect(screen.getByLabelText('비밀번호')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '로그인' })).toBeInTheDocument();
  });
  
  it('이메일이 비어있으면 에러를 표시한다', async () => {
    render(<LoginForm onSubmit={() => {}} />);
    
    // 빈 폼 제출
    await user.click(screen.getByRole('button', { name: '로그인' }));
    
    // 에러 메시지 확인
    expect(await screen.findByRole('alert')).toHaveTextContent('이메일을 입력해주세요');
  });
  
  it('올바른 이메일/비밀번호로 로그인할 수 있다', async () => {
    const onSubmit = vi.fn();
    render(<LoginForm onSubmit={onSubmit} />);
    
    // 입력
    await user.type(screen.getByLabelText('이메일'), 'user@example.com');
    await user.type(screen.getByLabelText('비밀번호'), 'password123');
    
    // 제출
    await user.click(screen.getByRole('button', { name: '로그인' }));
    
    // 검증
    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith({
        email: 'user@example.com',
        password: 'password123',
      });
    });
  });
  
  it('잘못된 이메일 형식을 거부한다', async () => {
    render(<LoginForm onSubmit={() => {}} />);
    
    await user.type(screen.getByLabelText('이메일'), 'notanemail');
    await user.tab();  // 블러 트리거
    
    expect(screen.getByRole('alert')).toHaveTextContent('올바른 이메일 형식');
  });
});
// MSW - API 목킹
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/products', ({ request }) => {
    const url = new URL(request.url);
    const category = url.searchParams.get('category');
    
    return HttpResponse.json({
      products: [
        { id: '1', name: '노트북', price: 1500000, category: 'electronics' },
        { id: '2', name: '마우스', price: 50000, category: 'electronics' },
      ].filter(p => !category || p.category === category),
      total: 2,
    });
  }),
  
  http.post('/api/products', async ({ request }) => {
    const body = await request.json() as { name: string; price: number };
    
    if (!body.name) {
      return HttpResponse.json({ error: '상품명 필수' }, { status: 400 });
    }
    
    return HttpResponse.json(
      { id: '3', ...body },
      { status: 201 }
    );
  }),
];

// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// 비동기 컴포넌트 테스트 (MSW 사용)
import { render, screen } from '@testing-library/react';
import { ProductList } from './ProductList';

describe('ProductList', () => {
  it('상품 목록을 로딩하고 표시한다', async () => {
    render(<ProductList />);
    
    // 로딩 상태 확인
    expect(screen.getByText('로딩 중...')).toBeInTheDocument();
    
    // 데이터 로딩 완료 대기
    const laptop = await screen.findByText('노트북');
    expect(laptop).toBeInTheDocument();
    expect(screen.getByText('₩1,500,000')).toBeInTheDocument();
  });
  
  it('API 에러 시 에러 메시지를 표시한다', async () => {
    // 특정 테스트에서만 핸들러 오버라이드
    server.use(
      http.get('/api/products', () => {
        return HttpResponse.json({ error: '서버 오류' }, { status: 500 });
      })
    );
    
    render(<ProductList />);
    
    expect(await screen.findByRole('alert')).toHaveTextContent('데이터를 불러올 수 없습니다');
  });
});

// 커스텀 훅 테스트
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('초기값을 반환한다', () => {
    const { result } = renderHook(() => useCounter(5));
    expect(result.current.count).toBe(5);
  });
  
  it('increment가 카운트를 1 증가시킨다', () => {
    const { result } = renderHook(() => useCounter(0));
    act(() => result.current.increment());
    expect(result.current.count).toBe(1);
  });
});

import { vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from './mocks/server';

마무리

Testing Library의 핵심 원칙은 "테스트는 소프트웨어가 어떻게 사용되는지를 닮아야 한다"이다. getByTestId 대신 getByRole을 쓰면 접근성까지 함께 검증된다. MSW는 API 응답을 네트워크 레이어에서 가로채기 때문에 실제 HTTP 클라이언트 코드를 그대로 테스트한다. userEventfireEvent보다 실제 브라우저 이벤트에 더 가깝게 시뮬레이션해서 더 신뢰할 수 있는 테스트를 만든다.