중급

React 강좌 09강 — 컴포넌트 테스팅: Jest + React Testing Library

🎯 학습 목표

  • Jest와 Vitest의 차이를 이해하고 Vite 프로젝트에 Vitest를 설정할 수 있다
  • render, screen.getByRole, userEvent.click 기본 테스트 패턴을 작성할 수 있다
  • 비동기 테스트에서 waitFor와 findBy*를 올바르게 사용한다
  • MSW로 API 목킹을 설정하는 방법을 이해한다
  • 좋은 테스트와 나쁜 테스트를 구분하는 기준을 안다

📖 핵심 개념 1 — Jest vs Vitest

Jest는 오랫동안 React 테스팅의 표준이었지만, Vite 기반 프로젝트에서는 설정이 복잡합니다. Vitest는 Vite와 동일한 설정을 공유하므로 Vite 프로젝트에 자연스럽게 통합됩니다.

// Vitest 설치 및 설정
// npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

// vite.config.js에 테스트 설정 추가
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',        // 브라우저 환경 시뮬레이션
    globals: true,               // describe, it, expect를 전역으로 사용
    setupFiles: './src/test/setup.js', // 테스트 전 실행할 설정 파일
  },
});
// src/test/setup.js
import '@testing-library/jest-dom'; // toBeInTheDocument 등 추가 매처

📖 핵심 개념 2 — 기본 테스트 패턴

React Testing Library의 핵심 철학: “구현 세부사항이 아니라 사용자 관점에서 테스트하라”

// components/Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import Counter from './Counter';

describe('Counter 컴포넌트', () => {
  it('초기 카운트 0이 화면에 보인다', () => {
    render(<Counter />);
    // getByRole: 접근성 역할(role)로 요소를 찾음 — 권장 방식
    expect(screen.getByRole('heading', { name: /카운트: 0/i })).toBeInTheDocument();
  });

  it('+1 버튼 클릭 시 카운트가 1 증가한다', async () => {
    // userEvent.setup()으로 사용자 이벤트 시뮬레이션
    const user = userEvent.setup();
    render(<Counter />);

    const button = screen.getByRole('button', { name: '+1' });
    await user.click(button);

    expect(screen.getByRole('heading', { name: /카운트: 1/i })).toBeInTheDocument();
  });

  it('초기화 버튼 클릭 시 0으로 돌아간다', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={5} />);

    await user.click(screen.getByRole('button', { name: '+1' }));
    await user.click(screen.getByRole('button', { name: '초기화' }));

    expect(screen.getByText('카운트: 0')).toBeInTheDocument();
  });
});

screen 쿼리 선택 우선순위

// 권장 순서 (접근성 기반 → 의미적 → 마지막 수단)
screen.getByRole('button', { name: '제출' })  // 1순위: role + 접근성 이름
screen.getByLabelText('이메일')               // 2순위: 폼 레이블
screen.getByPlaceholderText('이메일 입력')    // 3순위: placeholder
screen.getByText('환영합니다')                // 4순위: 텍스트 내용
screen.getByTestId('submit-btn')             // 최후 수단: data-testid

// ❌ 피해야 할 방식 — 구현 세부사항에 의존
document.querySelector('.submit-button')     // CSS 클래스 (구현 세부사항)
document.getElementById('btn-1')             // ID (구현 세부사항)

📖 핵심 개념 3 — 비동기 테스트

// components/UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';

// API를 모킹 (MSW 없이 간단히 모킹)
vi.mock('../api/userApi', () => ({
  fetchUser: vi.fn(() => Promise.resolve({ id: 1, name: '김개발' })),
}));

describe('UserProfile', () => {
  it('로딩 후 사용자 이름이 표시된다', async () => {
    render(<UserProfile userId={1} />);

    // 로딩 중 상태 확인
    expect(screen.getByText('로딩 중...')).toBeInTheDocument();

    // 방법 1: waitFor — 조건이 충족될 때까지 기다림
    await waitFor(() => {
      expect(screen.getByText('김개발')).toBeInTheDocument();
    });

    // 방법 2: findBy* — waitFor + getBy의 조합 (더 간결)
    // const nameEl = await screen.findByText('김개발');
    // expect(nameEl).toBeInTheDocument();
  });

  it('API 에러 시 에러 메시지가 표시된다', async () => {
    const { fetchUser } = await import('../api/userApi');
    fetchUser.mockRejectedValueOnce(new Error('서버 오류'));

    render(<UserProfile userId={999} />);

    const errorMsg = await screen.findByText(/에러:/i);
    expect(errorMsg).toBeInTheDocument();
  });
});

📖 핵심 개념 4 — MSW(Mock Service Worker)

MSW는 Service Worker를 활용해 네트워크 요청을 가로채는 API 목킹 도구입니다. 테스트 코드와 실제 코드 사이에 계층을 두지 않고, 실제 fetch 요청을 인터셉트하므로 더 현실적인 테스트가 가능합니다.

// npm install -D msw

// src/mocks/handlers.js — API 핸들러 정의
import { http, HttpResponse } from 'msw';

export const handlers = [
  // GET 요청 목킹
  http.get('https://api.example.com/users', () => {
    return HttpResponse.json([
      { id: 1, name: '김개발' },
      { id: 2, name: '박프론트' },
    ]);
  }),

  // 동적 파라미터
  http.get('https://api.example.com/users/:id', ({ params }) => {
    const { id } = params;
    return HttpResponse.json({ id: Number(id), name: `사용자 ${id}` });
  }),

  // POST 요청
  http.post('https://api.example.com/users', async ({ request }) => {
    const newUser = await request.json();
    return HttpResponse.json({ id: Date.now(), ...newUser }, { status: 201 });
  }),

  // 에러 응답 시뮬레이션
  http.get('https://api.example.com/error', () => {
    return HttpResponse.json({ message: '서버 내부 오류' }, { status: 500 });
  }),
];
// src/mocks/server.js — Node 환경(테스트)용 서버
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// src/test/setup.js에 추가
import { server } from '../mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // 테스트별 핸들러 초기화
afterAll(() => server.close());

💻 코드 예제 — 좋은 테스트 vs 나쁜 테스트

// ❌ 나쁜 테스트 — 구현 세부사항 테스트
it('useState가 올바르게 업데이트된다', () => {
  const { result } = renderHook(() => useState(0));
  act(() => result.current[1](1));
  expect(result.current[0]).toBe(1); // 내부 구현 테스트 — 리팩토링 시 깨짐
});

it('handleClick 함수가 호출된다', async () => {
  const handleClick = vi.fn();
  render(<Button onClick={handleClick} />);
  await userEvent.click(screen.getByRole('button'));
  expect(handleClick).toHaveBeenCalledTimes(1); // OK지만 사용자 행동 테스트가 더 좋음
});

// ✅ 좋은 테스트 — 사용자 관점의 행동 테스트
it('버튼 클릭 시 항목이 목록에 추가된다', async () => {
  const user = userEvent.setup();
  render(<TodoApp />);

  // 사용자가 하는 행동을 그대로 시뮬레이션
  await user.type(screen.getByPlaceholderText('할 일 입력'), 'React 테스트 공부');
  await user.click(screen.getByRole('button', { name: '추가' }));

  // 사용자가 볼 수 있는 결과를 확인
  expect(screen.getByText('React 테스트 공부')).toBeInTheDocument();
});

it('항목 삭제 시 목록에서 사라진다', async () => {
  const user = userEvent.setup();
  render(<TodoApp initialItems={[{ id: 1, text: '삭제할 항목', done: false }]} />);

  await user.click(screen.getByRole('button', { name: '삭제' }));

  expect(screen.queryByText('삭제할 항목')).not.toBeInTheDocument();
});

⚠️ 흔한 실수 (よくあるミス)

  • act() 경고 무시: “Warning: Not wrapped in act(…)”는 비동기 상태 업데이트가 테스트 외부에서 발생하고 있다는 신호입니다. waitForfindBy*로 처리하세요.
  • getBy vs queryBy vs findBy 혼동: getBy는 없으면 즉시 에러, queryBy는 없으면 null 반환(존재하지 않음 검증에 사용), findBy는 비동기로 기다림.
  • 테스트마다 독립성 확보 실패: 전역 상태나 모듈 캐시가 테스트 간에 공유되면 순서에 따라 결과가 달라집니다. afterEach에서 초기화하세요.
  • 스냅샷 테스트 남용: 스냅샷은 빠르게 구식이 되고, 의도를 드러내지 않습니다. 작은 컴포넌트의 구조 검증에만 제한적으로 사용하세요.

💡 실무 팁

  • 커버리지보다 품질: 100% 커버리지를 목표로 하다 보면 의미 없는 테스트가 늘어납니다. 비즈니스 로직과 사용자 인터랙션 중심으로 테스트하세요.
  • 테스트 피라미드: 단위 테스트(많음) → 통합 테스트(중간) → E2E 테스트(적음). E2E는 Playwright나 Cypress를 사용합니다.
  • MSW를 개발에도 활용: MSW의 브라우저 모드를 사용하면 백엔드 없이도 프론트엔드 개발을 시작할 수 있습니다.