목차
29 장

컴포넌트 테스팅 — Vitest + Testing Library

Vitest + React Testing Library로 컴포넌트와 훅 테스트. render · userEvent · mocking 패턴, Next.js 환경에서의 주의점, CI 통합까지.

28장 까지로 4부가 마무리됐습니다. 본 챕터부터 5부 (운영 · 테스트 · 배포)가 시작됩니다. 5부의 다섯 챕터는 “리액트를 만들 줄 안다"에서 “리액트로 일한다"로 넘어가는 다리입니다.

본 챕터는 그 첫 다리로 테스팅을 다룹니다. 이 책의 1~4부에서 만든 컴포넌트들은 화면에 그려졌고 직접 손으로 동작을 확인했습니다. 그런데 프로젝트가 커지면 손으로 모든 동작을 매번 확인할 수 없게 됩니다. 그때 안전망이 되는 게 자동화된 테스트입니다. 본 챕터에서는 컴포넌트와 훅 단위의 자동 테스트를, 다음 30장에서는 사용자 흐름 단위의 E2E 테스트를 다루겠습니다.

왜 Vitest인가 #

React 진영에서 오랫동안 표준은 Jest였습니다. 다만 다음 환경 변화로 Vitest가 새로운 표준으로 빠르게 올라왔습니다.

  • Vite와의 정합: Vite 프로젝트 (2장에서 만든 환경)의 설정을 그대로 재사용합니다. 별도의 babel 설정이나 transformer가 없습니다.
  • ESM 네이티브: 최신 라이브러리들이 ESM-only로 가는 흐름에서 Jest는 설정이 까다롭습니다. Vitest는 ESM을 기본으로 다룹니다.
  • 빠른 시작과 watch: Vite의 HMR을 그대로 활용해 테스트가 거의 즉시 다시 돕니다.
  • Jest 호환 API: describe / it / expect 시그니처가 거의 동일해서 Jest 자료를 그대로 응용할 수 있습니다.

Next.js 프로젝트에서도 Vitest를 쓸 수 있습니다. Jest를 이미 쓰고 있는 큰 코드베이스가 아니라면 새 프로젝트는 Vitest로 시작하는 게 표준입니다.

테스팅의 기본 원리 — 구현이 아닌 행동을 검증 #

Vitest를 설치하기 전에 먼저 짚고 갈 게 있습니다. 무엇을 테스트할 것인가.

흔한 함정은 컴포넌트의 내부 state나 props 흐름을 그대로 검증하려 드는 것입니다.

🚫 구현을 검증하는 테스트 — 깨지기 쉬움
test('카운터가 state를 올린다', () => {
  const wrapper = mount(<Counter />);
  expect(wrapper.state('count')).toBe(0);
  wrapper.instance().increment();
  expect(wrapper.state('count')).toBe(1);
});

이런 테스트는 컴포넌트 구현이 조금만 바뀌어도 (state 변수명, 내부 함수 이름) 깨집니다. 사용자는 state 변수에 신경 쓰지 않습니다. 사용자가 보는 건 화면상호작용입니다.

좋은 컴포넌트 테스트의 출발점.

사용자가 화면에서 무엇을 보는가, 사용자가 무엇을 하면 어떤 변화를 보는가를 검증한다.

이 관점이 React Testing Library가 처음부터 강제하는 철학이고, 이 책의 모든 컴포넌트 테스트도 이 관점을 출발점으로 삼겠습니다.

Vitest 셋업 #

2장에서 만든 Vite + React + TypeScript 프로젝트에 테스팅 도구를 추가합니다.

패키지 설치
pnpm add -D vitest @vitest/ui jsdom \
  @testing-library/react @testing-library/jest-dom @testing-library/user-event

각 패키지의 역할.

  • vitest: 테스트 러너 본체
  • @vitest/ui: 브라우저 기반 테스트 UI (옵션)
  • jsdom: 브라우저 DOM을 Node 환경에서 시뮬레이션
  • @testing-library/react: 컴포넌트 렌더링과 조회
  • @testing-library/jest-dom: toBeInTheDocument 등 DOM matcher
  • @testing-library/user-event: 키보드 / 클릭 등 실제 유저 인터랙션 시뮬레이션

vitest.config.ts를 만듭니다.

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
  },
});

src/test-setup.ts.

src/test-setup.ts
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

afterEach(() => {
  cleanup();
});

tsconfig.jsontypesvitest/globals를 추가하면 describe / it / expect를 import 없이 쓸 수 있습니다.

tsconfig.json (일부)
{
  "compilerOptions": {
    "types": ["vitest/globals", "@testing-library/jest-dom"]
  }
}

package.json에 스크립트.

package.json (일부)
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui"
  }
}

pnpm test로 watch 모드, pnpm test:run으로 CI에서 한 번만 실행하는 모드입니다.

첫 컴포넌트 테스트 #

5장에서 만든 Counter 컴포넌트를 다시 가져옵니다.

src/Counter.tsx
import { useState } from 'react';

type Props = {
  initial?: number;
};

export default function Counter({ initial = 0 }: Props) {
  const [count, setCount] = useState(initial);
  return (
    <div>
      <p>현재 : {count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <button onClick={() => setCount(initial)}>리셋</button>
    </div>
  );
}

테스트.

src/Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

describe('Counter', () => {
  it('초기 값을 표시한다', () => {
    render(<Counter initial={5} />);
    expect(screen.getByText('현재 값: 5')).toBeInTheDocument();
  });

  it('+1 버튼을 누르면 값이 1 증가한다', async () => {
    const user = userEvent.setup();
    render(<Counter />);

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

    expect(screen.getByText('현재 값: 2')).toBeInTheDocument();
  });

  it('리셋 버튼을 누르면 initial로 돌아간다', async () => {
    const user = userEvent.setup();
    render(<Counter initial={10} />);

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

    expect(screen.getByText('현재 값: 10')).toBeInTheDocument();
  });
});

이 테스트가 검증하는 것.

  • 화면에 보이는 텍스트 (screen.getByText)
  • 사용자의 클릭 (user.click)
  • 클릭 후의 화면 변화

count state가 어떻게 관리되는지, useState를 쓰는지 useReducer를 쓰는지, 내부 함수 이름이 무엇인지 — 전혀 검증하지 않습니다. 사용자의 시선에서 검증합니다.

queryBy / findBy / getBy — 셋의 차이 #

Testing Library의 조회 함수는 세 가지 prefix가 있고, 각각의 쓰임이 다릅니다.

prefix못 찾으면비동기 대기주 사용처
getBythrow안 함지금 당장 있어야 하는 요소
queryBynull 반환안 함“없음"을 단언할 때
findBythrow대기함비동기로 곧 나타날 요소

getBy는 화면에 즉시 있어야 할 요소를 단언합니다. 없으면 throw하면서 화면을 친절하게 출력해 줍니다.

queryBy는 “있으면 안 되는” 요소를 검증할 때 씁니다.

queryBy 사용 예
expect(screen.queryByText('에러')).not.toBeInTheDocument();

findBy는 비동기 작업 후 나타나는 요소를 기다립니다. 내부적으로 Testing Library의 waitFor가 동작합니다.

findBy 사용 예
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(await screen.findByText('환영합니다')).toBeInTheDocument();

순서가 중요합니다.

  1. getBy* 먼저 시도 — 동기적이라 빠르고 메시지가 명확합니다.
  2. 비동기로 나타나는 요소면 findBy*.
  3. “없음"을 단언할 때만 queryBy*.

상호작용 테스트 — userEvent의 사용 #

userEvent는 실제 사용자처럼 키보드 입력과 클릭을 시뮬레이션합니다. 단순한 fireEvent보다 현실에 가깝습니다 (예: type은 한 글자씩 입력하면서 각 키의 이벤트를 모두 발생시킵니다).

폼 입력 테스트
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

it('로그인 폼이 사용자 입력과 제출을 처리한다', async () => {
  const user = userEvent.setup();
  const handleSubmit = vi.fn();

  render(<LoginForm onSubmit={handleSubmit} />);

  await user.type(screen.getByLabelText('이메일'), 'me@example.com');
  await user.type(screen.getByLabelText('비밀번호'), 'secret123');
  await user.click(screen.getByRole('button', { name: '로그인' }));

  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'me@example.com',
    password: 'secret123',
  });
});

getByLabelText<label>로 연결된 입력을 찾습니다. 접근성 마크업과 자연스럽게 맞물려, 사용자가 보는 라벨을 그대로 셀렉터로 쓰는 흐름입니다.

getByRole은 ARIA role을 기준으로 찾습니다. 'button' / 'textbox' / 'heading' 등이 자주 쓰입니다. 접근성 친화 셀렉터를 우선하면 테스트가 자연스럽게 a11y를 검증하는 효과도 납니다.

mocking — vi.mock #

외부 모듈(예: fetch wrapper, API client)을 mocking할 때 vi.mock을 씁니다.

모듈 mocking 예
import { render, screen } from '@testing-library/react';
import { vi } from 'vitest';
import PostList from './PostList';

vi.mock('./api', () => ({
  fetchPosts: vi.fn().mockResolvedValue([
    { id: '1', title: '첫 글' },
    { id: '2', title: '둘째 글' },
  ]),
}));

it('포스트 목록을 그린다', async () => {
  render(<PostList />);
  expect(await screen.findByText('첫 글')).toBeInTheDocument();
  expect(await screen.findByText('둘째 글')).toBeInTheDocument();
});

vi.mock의 첫 인자는 모듈 경로, 두 번째는 mock의 모양을 반환하는 팩토리 함수입니다.

MSW가 필요한 경우 #

여러 테스트가 같은 API를 mock해야 하거나, 네트워크 레이어 자체를 가로채고 싶다면 **MSW (Mock Service Worker)**가 더 좋은 선택입니다.

MSW 사용 예 (개념)
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('/api/posts', () =>
    HttpResponse.json([{ id: '1', title: '첫 글' }])
  ),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

MSW는 fetch / XHR을 가로채 네트워크 레벨에서 응답합니다. 같은 핸들러를 30장 (Playwright)에서도 재사용할 수 있어 테스트 레벨이 달라도 같은 mock을 공유할 수 있다는 장점이 큽니다.

훅 테스트 — renderHook #

훅 단독으로 동작을 검증하고 싶을 때 renderHook을 씁니다. 13장에서 만든 useToggle 같은 커스텀 훅이 대상입니다.

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

it('useToggle이 boolean을 토글한다', () => {
  const { result } = renderHook(() => useToggle(false));

  expect(result.current[0]).toBe(false);

  act(() => result.current[1]());

  expect(result.current[0]).toBe(true);
});

act는 state 업데이트를 React의 렌더 사이클 안으로 묶어 줍니다. 직접 호출하지 않으면 React가 경고를 띄웁니다.

훅을 단위로 테스트할까, 통합으로 테스트할까 #

훅이 복잡하지 않고 어차피 컴포넌트 안에서만 쓰인다면, 그 컴포넌트의 행동 테스트가 훅도 같이 검증합니다. 굳이 renderHook을 또 쓸 필요가 없습니다.

renderHook이 특히 유용한 경우.

  • 훅이 여러 컴포넌트에서 쓰이는 라이브러리성 코드.
  • 훅 내부에 분기가 많아 모든 케이스를 컴포넌트로 만들기에 번거로운 경우.
  • 컴포넌트와 훅의 책임을 분리해 가며 만들고 있을 때.

대부분의 애플리케이션 훅은 통합 테스트로 충분합니다.

Next.js 컴포넌트 테스트 — 주의점 #

Next.js 프로젝트에서 Vitest를 쓸 때 알아 둘 한계가 있습니다.

Server Component 직접 테스트 #

Server Component는 서버에서 한 번 실행되어 결과를 HTML로 내보내는 모델입니다. Vitest의 jsdom 환경에서 직접 렌더하는 건 부분적으로만 가능합니다. 특히 async 함수 컴포넌트나 RSC 전용 API (headers, cookies 등)를 쓰는 코드는 단위 테스트로 다루기 어렵습니다.

원칙: Server Component의 동작 검증은 30장 Playwright의 E2E 테스트로 위임하는 게 자연스럽습니다. 컴포넌트 단위 테스트는 Client Component와 순수 함수에 집중합니다.

Client Component는 정상 작동 #

'use client'가 붙은 컴포넌트는 일반 리액트 컴포넌트로 취급되어 Vitest에서 잘 동작합니다. Server Action을 import하는 코드는 mock으로 가로채는 게 표준입니다.

Server Action mocking
vi.mock('./actions', () => ({
  postMessage: vi.fn().mockResolvedValue({ success: true }),
}));

App Router의 next/navigation mocking #

useRouter, useSearchParams 같은 훅을 쓰는 컴포넌트는 mocking이 필요합니다.

next/navigation mocking
vi.mock('next/navigation', () => ({
  useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
  useSearchParams: () => new URLSearchParams(),
}));

CI 통합 — GitHub Actions #

테스트는 로컬에서만 돌면 의미가 반입니다. CI에서 PR마다 돌아야 진짜 안전망이 됩니다.

.github/workflows/test.yml:

.github/workflows/test.yml
name: test

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm test:run
      - run: pnpm test:run -- --coverage

pnpm test:run이 CI에서 한 번만 실행하는 모드입니다 (watch가 아닌). coverage를 매번 측정하면 비용이 늘 수 있어 별도 job으로 분리하는 패턴도 흔합니다.

Coverage 측정 #

vitest.config.ts (coverage 추가)
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: ['**/*.test.{ts,tsx}', 'src/test-setup.ts'],
    },
  },
});

v8 provider는 별도 패키지 없이 Node의 내장 커버리지를 씁니다. 빠르고 설정이 적습니다.

목표 커버리지를 강제로 매기지는 마세요. 테스트하기 좋은 코드를 짜고 의미 있는 테스트를 쓰는 게 우선이고, 숫자는 결과 지표일 뿐입니다.

테스트 레벨의 역할 분담 #

5부 첫 챕터답게 큰 그림을 한 번 그려 두겠습니다. 다음 30장 Playwright와의 분담입니다.

레벨도구검증 대상속도비중
단위Vitest순수 함수, 훅, 작은 컴포넌트매우 빠름알고리즘, 분기, 엣지 케이스
통합Vitest + jsdom여러 컴포넌트의 협업빠름폼 흐름, state 끌어올리기, Context
E2EPlaywright (30장)사용자 시나리오 전체느림회원가입→로그인→Todo 추가→완료 같은 풀 시나리오

세 레벨이 다 필요한 게 아닙니다. 작은 프로젝트는 Vitest 단위 + 핵심 흐름 한두 개의 E2E면 충분합니다. 큰 프로젝트는 세 레벨을 균형 있게 둡니다.

피라미드 모양을 기억해 두세요. 단위 테스트가 가장 많고, E2E가 가장 적게. 거꾸로 뒤집으면 CI가 느려져 PR이 쌓이고, 결국 테스트를 끄게 됩니다.

직접 해보기 — 27장 방명록에 테스트 입히기 #

27장에서 만든 방명록의 MessageForm에 테스트를 입혀 보겠습니다.

  1. Server Action mocking: vi.mock('./actions', ...)으로 postMessage를 mock하세요. 성공 케이스({ success: true })와 검증 실패 케이스({ error: '이름을 입력해 주세요' })두 가지를 준비합니다.
  2. 빈 입력 검증: 이름 / 메시지를 비운 채 제출 버튼을 클릭했을 때 에러 메시지가 화면에 나타나는지 검증하세요. screen.findByText로 비동기 등장을 기다립니다.
  3. 성공 케이스: 이름과 메시지를 채우고 제출하면 폼이 reset되는지 검증하세요. screen.getByLabelText('이름')의 value가 빈 문자열로 돌아가는 것까지 확인합니다.
  4. SubmitButton의 pending: 별도 컴포넌트로 분리된 SubmitButton의 disabled 상태가 제출 중에 어떻게 변하는지 검증하세요. useFormStatus가 polyfill 또는 mock 처리가 필요할 수 있습니다.

세 시나리오를 작성하고 나면 React Testing Library의 핵심 패턴 — render + userEvent + findBy + Server Action mock — 이 손에 익습니다.

연습문제 #

  1. getBy / findBy / queryBy 선택. 다음 세 상황 각각에 어떤 prefix를 쓰는 게 적절한지 답하고 이유를 적어 보세요. (a) 페이지 로드 직후 환영 메시지가 있는지 확인, (b) 로그인 버튼을 누른 뒤 환영 메시지가 나타나는지 확인, (c) 에러 메시지가 없는 상태인지 확인. 답을 적은 뒤 본문의 표와 비교합니다.
  2. 구현 vs 행동 테스트 식별. 5장 (useState)의 예제 코드에 다음 두 테스트를 가정합니다. (a) “버튼을 누르면 count state가 1 증가한다”, (b) “버튼을 누르면 화면의 ‘현재 값’ 텍스트가 1 증가한다”. 둘 중 어느 쪽이 더 안정적이고, 컴포넌트를 useReducer로 리팩터링해도 계속 통과할지 설명해 보세요.
  3. 테스트 피라미드 적용. 6부 34장의 풀스택 Todo 앱에 어떤 테스트를 어디에 두면 좋을지 설계해 보세요. (a) Todo 항목 정렬 함수, (b) MessageForm의 검증 흐름, (c) 회원가입→로그인→Todo 추가의 풀 시나리오 — 각각이 단위 / 통합 / E2E 중 어디에 적합한지 분류하고 이유를 한 줄씩 적습니다.

한 줄 요약: Vitest + React Testing Library는 “구현이 아닌 행동을 검증"하는 테스팅의 표준 조합이다. getBy / findBy / queryBy의 쓰임을 익히고, userEvent로 사용자처럼 조작하고, vi.mock으로 외부 의존을 가른다. Next.js의 Server Component는 단위 테스트의 한계가 있어 30장 Playwright의 E2E로 위임하는 게 자연스럽다. 단위 → 통합 → E2E의 피라미드를 기억하고, 숫자 커버리지보다 의미 있는 테스트를 우선한다.

다음 챕터 #

다음 30장 E2E 테스팅 — Playwright에서는 본 챕터에서 다루지 못한 사용자 시나리오 단위의 자동 테스트를 다루겠습니다. Vitest가 jsdom 안에서 컴포넌트를 검증했다면, Playwright는 실제 브라우저를 띄워 회원가입 → 로그인 → Todo 추가 같은 풀 흐름을 자동화합니다. 그리고 본 챕터의 mock 패턴과 자연스럽게 연결되도록, MSW 같은 도구를 양쪽에서 공유하는 흐름까지 잡아 보겠습니다.

X