컴포넌트 테스팅 — 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를 만듭니다.
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.
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});tsconfig.json의 types에 vitest/globals를 추가하면 describe / it / expect를 import 없이 쓸 수 있습니다.
{
"compilerOptions": {
"types": ["vitest/globals", "@testing-library/jest-dom"]
}
}package.json에 스크립트.
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui"
}
}pnpm test로 watch 모드, pnpm test:run으로 CI에서 한 번만 실행하는 모드입니다.
첫 컴포넌트 테스트 #
5장에서 만든 Counter 컴포넌트를 다시 가져옵니다.
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>
);
}테스트.
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 | 못 찾으면 | 비동기 대기 | 주 사용처 |
|---|---|---|---|
getBy | throw | 안 함 | 지금 당장 있어야 하는 요소 |
queryBy | null 반환 | 안 함 | “없음"을 단언할 때 |
findBy | throw | 대기함 | 비동기로 곧 나타날 요소 |
getBy는 화면에 즉시 있어야 할 요소를 단언합니다. 없으면 throw하면서 화면을 친절하게 출력해 줍니다.
queryBy는 “있으면 안 되는” 요소를 검증할 때 씁니다.
expect(screen.queryByText('에러')).not.toBeInTheDocument();findBy는 비동기 작업 후 나타나는 요소를 기다립니다. 내부적으로 Testing Library의 waitFor가 동작합니다.
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(await screen.findByText('환영합니다')).toBeInTheDocument();순서가 중요합니다.
getBy*먼저 시도 — 동기적이라 빠르고 메시지가 명확합니다.- 비동기로 나타나는 요소면
findBy*. - “없음"을 단언할 때만
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을 씁니다.
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)**가 더 좋은 선택입니다.
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으로 가로채는 게 표준입니다.
vi.mock('./actions', () => ({
postMessage: vi.fn().mockResolvedValue({ success: true }),
}));App Router의 next/navigation mocking
#
useRouter, useSearchParams 같은 훅을 쓰는 컴포넌트는 mocking이 필요합니다.
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));CI 통합 — GitHub Actions #
테스트는 로컬에서만 돌면 의미가 반입니다. CI에서 PR마다 돌아야 진짜 안전망이 됩니다.
.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 -- --coveragepnpm test:run이 CI에서 한 번만 실행하는 모드입니다 (watch가 아닌). coverage를 매번 측정하면 비용이 늘 수 있어 별도 job으로 분리하는 패턴도 흔합니다.
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 |
| E2E | Playwright (30장) | 사용자 시나리오 전체 | 느림 | 회원가입→로그인→Todo 추가→완료 같은 풀 시나리오 |
세 레벨이 다 필요한 게 아닙니다. 작은 프로젝트는 Vitest 단위 + 핵심 흐름 한두 개의 E2E면 충분합니다. 큰 프로젝트는 세 레벨을 균형 있게 둡니다.
피라미드 모양을 기억해 두세요. 단위 테스트가 가장 많고, E2E가 가장 적게. 거꾸로 뒤집으면 CI가 느려져 PR이 쌓이고, 결국 테스트를 끄게 됩니다.
직접 해보기 — 27장 방명록에 테스트 입히기 #
27장에서 만든 방명록의 MessageForm에 테스트를 입혀 보겠습니다.
- Server Action mocking:
vi.mock('./actions', ...)으로postMessage를 mock하세요. 성공 케이스({ success: true })와 검증 실패 케이스({ error: '이름을 입력해 주세요' })두 가지를 준비합니다. - 빈 입력 검증: 이름 / 메시지를 비운 채 제출 버튼을 클릭했을 때 에러 메시지가 화면에 나타나는지 검증하세요.
screen.findByText로 비동기 등장을 기다립니다. - 성공 케이스: 이름과 메시지를 채우고 제출하면 폼이 reset되는지 검증하세요.
screen.getByLabelText('이름')의 value가 빈 문자열로 돌아가는 것까지 확인합니다. - SubmitButton의 pending: 별도 컴포넌트로 분리된 SubmitButton의 disabled 상태가 제출 중에 어떻게 변하는지 검증하세요.
useFormStatus가 polyfill 또는 mock 처리가 필요할 수 있습니다.
세 시나리오를 작성하고 나면 React Testing Library의 핵심 패턴 — render + userEvent + findBy + Server Action mock — 이 손에 익습니다.
연습문제 #
- getBy / findBy / queryBy 선택. 다음 세 상황 각각에 어떤 prefix를 쓰는 게 적절한지 답하고 이유를 적어 보세요. (a) 페이지 로드 직후 환영 메시지가 있는지 확인, (b) 로그인 버튼을 누른 뒤 환영 메시지가 나타나는지 확인, (c) 에러 메시지가 없는 상태인지 확인. 답을 적은 뒤 본문의 표와 비교합니다.
- 구현 vs 행동 테스트 식별. 5장 (useState)의 예제 코드에 다음 두 테스트를 가정합니다. (a) “버튼을 누르면 count state가 1 증가한다”, (b) “버튼을 누르면 화면의 ‘현재 값’ 텍스트가 1 증가한다”. 둘 중 어느 쪽이 더 안정적이고, 컴포넌트를 useReducer로 리팩터링해도 계속 통과할지 설명해 보세요.
- 테스트 피라미드 적용. 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 같은 도구를 양쪽에서 공유하는 흐름까지 잡아 보겠습니다.