
Vitest + Testing Library: 테스트 코드를 처음 짰다
프론트엔드 테스트를 처음 도입하면서 겪은 시행착오. Vitest와 Testing Library 조합으로 React 컴포넌트를 테스트하는 실전 패턴을 정리했다.

프론트엔드 테스트를 처음 도입하면서 겪은 시행착오. Vitest와 Testing Library 조합으로 React 컴포넌트를 테스트하는 실전 패턴을 정리했다.
코드를 먼저 짜고 테스트하는 게 아닙니다. 테스트를 먼저 짜고, 그걸 통과하기 위해 코딩하는 것. 순서를 뒤집으면 버그가 사라집니다.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

React나 Vue 프로젝트를 빌드해서 배포했는데, 홈 화면은 잘 나오지만 새로고침만 하면 'Page Not Found'가 뜨나요? CSR의 원리와 서버 설정(Nginx, Apache, S3)을 통해 이를 해결하는 완벽 가이드.

진짜 집을 부수고 다시 짓는 건 비쌉니다. 설계도(가상돔)에서 미리 그려보고 바뀐 부분만 공사하는 '똑똑한 리모델링'의 기술.

배포 직후였다. 폼 컴포넌트를 리팩토링하고, 스타일도 다듬고, "이거 꽤 잘 됐는데" 싶어서 PR 머지했다. 그런데 배포 몇 분 만에 슬랙에 메시지가 올라왔다.
"회원가입이 안 돼요. 제출 버튼이 아무 반응이 없어요."
식은땀이 났다. 확인해보니 폼 제출 핸들러가 완전히 날아가 있었다. 리팩토링하면서 onSubmit을 연결하는 코드 한 줄을 실수로 지웠는데, 아무도 몰랐다. 나도 몰랐다. 눈으로 보기엔 버튼도 있고, 인풋도 있고, 다 멀쩡해 보였으니까. 기능이 동작하는지는 아무도 확인하지 않았다.
그게 테스트 코드를 써야겠다고 결심한 날이다.
처음엔 Jest를 붙이려 했다. 그런데 Vite 기반 프로젝트에 Jest를 붙이는 설정이 생각보다 복잡했다. Babel 트랜스파일 설정, moduleNameMapper, transform 설정... 반나절을 설정 파일만 만지다가 포기했다. "이거 설정하다 지쳐서 테스트 자체를 안 쓰게 될 것 같다"는 생각이 들었다.
그때 Vitest를 발견했다.
Vitest는 Vite 기반 프로젝트용 테스트 프레임워크다. 가장 중요한 특징은 이거다. Vite 설정을 그대로 재사용한다. 별도의 Babel 설정, 별도의 모듈 해석 설정 없이, 기존 vite.config.ts에 몇 줄만 추가하면 끝난다.
Jest와 API가 거의 동일하다. describe, it, expect, beforeEach, afterEach. 익숙한 패턴이 그대로다. 마이그레이션 비용이 거의 없다. 그리고 빠르다. HMR 기반으로 변경된 파일과 관련된 테스트만 다시 실행한다.
설치는 이렇다.
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
그리고 vite.config.ts에 추가.
/// <reference types="vitest" />
import { defineConfig } from 'vite';
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';
끝이다. 설정은 진짜 이게 전부다. Jest 설정하다 반나절 날린 것과 비교하면 믿기 힘들 정도로 간단하다.
Testing Library가 처음엔 낯설었다. 컴포넌트의 내부 상태를 직접 확인하거나, 메서드를 직접 호출하거나, props를 조작하는 방식이 아니다. 대신 이런 질문을 던진다.
"실제 사용자라면 이 컴포넌트를 어떻게 보고, 어떻게 상호작용하는가?"이게 처음엔 이상하게 느껴진다. "상태를 확인하면 더 정확한 거 아닌가?" 싶다. 그런데 생각해보면, 사용자는 컴포넌트의 내부 state를 모른다. 사용자가 보는 건 화면에 렌더링된 텍스트, 버튼, 인풋이다.
Testing Library를 이해하고 나니 비유 하나가 떠올랐다. 자동차 검사소다.
차를 검사할 때 엔진을 뜯어서 피스톤 하나하나를 확인하지 않는다. 시동이 켜지는지, 브레이크가 잘 서는지, 방향 지시등이 깜빡이는지를 확인한다. 사용자가 차를 쓸 때 경험하는 것들을 테스트하는 것이다. 내부 구현이 어떻게 바뀌든 외부 동작이 같으면 테스트는 통과한다.
이게 Testing Library가 추구하는 방향이다. 내부 구현이 아니라 사용자가 경험하는 동작을 테스트한다.
세 가지만 알면 80%는 커버된다.
render(): 컴포넌트를 jsdom에 렌더링한다. 실제 브라우저처럼 HTML이 생성된다.screen: 렌더링된 DOM에서 요소를 찾는다. getByRole, getByText, getByLabelText 등.userEvent: 실제 사용자 동작을 시뮬레이션한다. 클릭, 타이핑, 포커스 등.screen.getByRole('button', { name: '제출' })이 처음엔 어색하다. 왜 getByText('제출') 대신 getByRole을 쓰는가? 이유는 접근성이다. 역할(role)로 요소를 찾는 방식이 실제 스크린 리더가 DOM을 해석하는 방식과 같기 때문에, 접근성 문제를 자연스럽게 잡을 수 있다.
처음으로 쓴 테스트가 바로 그 회원가입 폼이었다. 재발 방지가 목적이었다.
// src/components/SignupForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SignupForm } from './SignupForm';
describe('SignupForm', () => {
it('이메일과 비밀번호를 입력하고 제출하면 onSubmit이 호출된다', async () => {
const user = userEvent.setup();
const mockOnSubmit = vi.fn();
render(<SignupForm onSubmit={mockOnSubmit} />);
// 사용자가 이메일 필드를 찾아 입력한다
await user.type(
screen.getByLabelText('이메일'),
'test@example.com'
);
// 비밀번호 필드에 입력한다
await user.type(
screen.getByLabelText('비밀번호'),
'password123'
);
// 제출 버튼을 클릭한다
await user.click(screen.getByRole('button', { name: '회원가입' }));
// onSubmit이 올바른 데이터로 호출됐는지 확인한다
expect(mockOnSubmit).toHaveBeenCalledOnce();
expect(mockOnSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
it('이메일 형식이 잘못되면 에러 메시지를 보여준다', async () => {
const user = userEvent.setup();
render(<SignupForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText('이메일'), 'not-an-email');
await user.click(screen.getByRole('button', { name: '회원가입' }));
// 사용자에게 보이는 에러 메시지를 확인한다
expect(
screen.getByText('올바른 이메일 형식을 입력해주세요.')
).toBeInTheDocument();
});
it('빈 폼을 제출하면 onSubmit이 호출되지 않는다', async () => {
const user = userEvent.setup();
const mockOnSubmit = vi.fn();
render(<SignupForm onSubmit={mockOnSubmit} />);
await user.click(screen.getByRole('button', { name: '회원가입' }));
expect(mockOnSubmit).not.toHaveBeenCalled();
});
});
이 테스트가 있었다면, 리팩토링 중에 onSubmit을 날렸을 때 바로 잡혔을 것이다. mockOnSubmit이 호출되지 않았다고 빨간 불이 켜졌을 것이다. 배포 전에.
두 번째로 어려웠던 건 API를 호출하는 컴포넌트 테스트였다. 실제 API를 부를 수는 없으니 모킹(mocking)이 필요하다.
// src/components/PostList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { PostList } from './PostList';
// fetch를 모킹한다
const mockPosts = [
{ id: 1, title: '첫 번째 글', slug: 'first-post' },
{ id: 2, title: '두 번째 글', slug: 'second-post' },
];
beforeEach(() => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockPosts,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('PostList', () => {
it('로딩 중에 스피너를 보여준다', () => {
render(<PostList />);
// 데이터가 오기 전, 로딩 상태를 확인한다
expect(screen.getByRole('status')).toBeInTheDocument(); // <div role="status" />
});
it('데이터를 불러온 후 글 목록을 렌더링한다', async () => {
render(<PostList />);
// 비동기 렌더링이 완료될 때까지 기다린다
await waitFor(() => {
expect(screen.getByText('첫 번째 글')).toBeInTheDocument();
});
expect(screen.getByText('두 번째 글')).toBeInTheDocument();
expect(screen.getAllByRole('article')).toHaveLength(2);
});
it('API 오류 시 에러 메시지를 보여준다', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
render(<PostList />);
await waitFor(() => {
expect(
screen.getByText('데이터를 불러오는 데 실패했습니다.')
).toBeInTheDocument();
});
});
});
여기서 waitFor가 중요하다. 비동기 동작은 await waitFor()로 감싸야 한다. "이 조건이 참이 될 때까지 기다려라"는 뜻이다. 그냥 expect를 쓰면 데이터가 로드되기 전에 확인하기 때문에 무조건 실패한다.
테스트를 처음 쓸 때 가장 헷갈리는 게 이거다. "뭘 테스트해야 하는가?" 모든 코드를 테스트하려다 지쳐서 테스트 자체를 포기하게 된다.
사용자가 직접 경험하는 동작. 클릭하면 모달이 열린다. 폼을 제출하면 API가 호출된다. 에러가 나면 메시지가 보인다. 이런 것들.
비즈니스 로직. 할인율 계산, 유효성 검사, 데이터 변환. 이건 UI와 분리해서 순수 함수로 만들고 테스트하기 쉽다.
경계 케이스. 빈 배열, null, undefined, 아주 긴 문자열, 0, 음수. 이런 입력이 들어왔을 때 컴포넌트가 어떻게 처리하는지.
내부 구현 세부사항. 컴포넌트가 어떤 state를 어떻게 관리하는지, 어떤 함수를 내부적으로 호출하는지. 이런 건 리팩토링하면 테스트가 다 깨진다. 테스트가 오히려 리팩토링을 방해하는 역할이 된다.
서드파티 라이브러리 동작. react-hook-form이 유효성 검사를 제대로 하는지 테스트할 필요 없다. 그건 라이브러리 자체 테스트에서 이미 검증됐다.
스타일. CSS 클래스 이름이 맞는지, color가 #ff0000인지 같은 건 단위 테스트로 검증하는 게 아니다. 눈으로 보거나 스냅샷 테스트가 맞다.
커스텀 훅을 테스트할 때는 renderHook을 쓴다. 훅은 컴포넌트 안에서만 쓸 수 있으니, 테스트 전용 컴포넌트를 직접 만들 필요 없이 renderHook이 대신해준다.
// src/hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('초기값이 0이다', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('increment를 호출하면 카운트가 1 증가한다', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('initialValue를 전달하면 그 값으로 시작한다', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
});
act()는 상태 변경을 일으키는 모든 동작을 감싸야 한다. 이 안에서 일어나는 상태 변경이 DOM에 반영된 뒤에 expect를 실행한다는 의미다.
이 비유가 가장 잘 맞는다. 서커스 공중 그네를 타는 사람을 생각해봐라. 안전망이 없어도 공연은 할 수 있다. 하지만 실수하면 끝이다. 안전망이 있으면? 실수해도 다시 올라갈 수 있다. 공연이 더 과감해진다.
코드도 마찬가지다. 테스트가 없으면 배포가 두렵다. 리팩토링이 두렵다. "혹시 이거 건드리면 다른 게 깨지지 않을까?" 하는 불안 때문에 코드가 점점 레거시화된다. 테스트가 있으면 코드를 과감하게 바꿀 수 있다. 테스트가 통과하면 자신 있게 배포할 수 있다.
물론 테스트는 공짜가 아니다. 쓰는 시간이 든다. 유지보수도 필요하다. 하지만 그 비용은 프로덕션 버그 하나 잡는 비용, 사용자 신뢰를 잃는 비용에 비하면 훨씬 작다.
Vite 기반 프로젝트엔 Vitest가 최선이다. Jest 설정과 씨름할 필요 없이, vite.config.ts에 몇 줄 추가하면 끝난다. API도 Jest와 거의 동일하다.
Testing Library는 내부 구현이 아니라 사용자 동작을 테스트한다. getByRole, getByLabelText, userEvent로 실제 사용자처럼 상호작용하고 검증한다. 리팩토링에 강하다.
비동기 테스트는 waitFor로 감싼다. 데이터 페칭, setTimeout, Promise 결과를 확인할 때는 반드시 기다려야 한다.
커스텀 훅은 renderHook으로 격리해서 테스트한다. 훅 전용 테스트 컴포넌트를 직접 만들 필요가 없다.
뭘 테스트할지 기준을 세워라. 사용자가 경험하는 동작과 비즈니스 로직을 테스트한다. 내부 구현 세부사항, 서드파티 라이브러리, CSS는 테스트하지 않는다.
테스트를 처음 쓴다면, 완벽하게 하려 하지 말고 가장 두렵고 중요한 부분 하나부터 시작하면 된다. 나한테는 그게 회원가입 폼이었다. 그다음엔 자연스럽게 퍼진다. 테스트가 실제로 버그를 잡아주는 경험을 한 번 하면, 이후엔 안 쓰는 게 더 불안해진다.
It happened right after a deploy. I'd refactored a form component, cleaned up the styles, thought "this looks solid," and merged the PR. A few minutes after deploy, a Slack message appeared.
"Sign-up is broken. The submit button doesn't do anything."
Cold sweat. I checked the code. During refactoring, I'd accidentally deleted the single line connecting onSubmit to the form element. Nobody caught it. I didn't catch it. The form looked fine visually—the button was there, the inputs were there—but nobody had actually verified the thing worked.
That was the day I decided to write tests.
My first instinct was Jest. But hooking Jest into a Vite-based project turned out to be more painful than expected. Babel transform configs, moduleNameMapper, transform options—I spent half a day on configuration and nearly gave up before writing a single test. "I'm going to burn out on setup and never actually write tests," I thought.
That's when I found Vitest.
Vitest is a test framework built specifically for Vite projects. The key insight: it reuses your existing Vite config. No separate Babel setup, no custom module resolution—just a few lines added to vite.config.ts and you're running tests.
The API is nearly identical to Jest. describe, it, expect, beforeEach, afterEach. If you've written Jest tests before, you already know Vitest. Migration cost is almost zero.
It's also fast. Vitest uses Vite's HMR-based module graph to re-run only tests affected by changed files. On large codebases, that's a meaningful difference.
Setup:
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
Then vite.config.ts:
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
},
});
And src/test/setup.ts:
import '@testing-library/jest-dom';
That's it. Genuinely. Compare that to the Jest config sprawl and you'll understand why Vitest exists.
Testing Library felt wrong to me at first. Where's the API for inspecting internal state? How do I call component methods directly? Why can't I just check whether a specific state variable changed?
The answer is that Testing Library doesn't want you to do any of that. It asks a different question: "How would a real user see and interact with this component?"
Think of it like a car inspection. When your car gets its annual check, the inspector doesn't pull apart the engine to examine each piston. They test whether the car starts, whether the brakes work, whether the turn signals flash. They test the things the driver experiences—not internal implementation details.
That's exactly what Testing Library pushes you toward. Test the behavior a user experiences. If the internal implementation changes completely but the user experience stays the same, the tests should still pass. That's the goal.
render() — Renders your component into jsdom. Real HTML is generated, just like a browser would produce.
screen — Queries the rendered DOM. getByRole, getByText, getByLabelText, and so on.
userEvent — Simulates real user interactions. Clicking, typing, focusing, tabbing.
screen.getByRole('button', { name: '제출' }) might look strange at first. Why getByRole instead of getByText? Because roles are how screen readers interpret the DOM. Querying by role means your test naturally surfaces accessibility issues—if the button doesn't have the right role, both your test and screen reader users will be confused.
The first test I wrote was for that signup form. Pure defensive measure—make sure this specific failure can never reach production again.
// src/components/SignupForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SignupForm } from './SignupForm';
describe('SignupForm', () => {
it('calls onSubmit with form data when submitted', async () => {
const user = userEvent.setup();
const mockOnSubmit = vi.fn();
render(<SignupForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText('이메일'), 'test@example.com');
await user.type(screen.getByLabelText('비밀번호'), 'password123');
await user.click(screen.getByRole('button', { name: '회원가입' }));
expect(mockOnSubmit).toHaveBeenCalledOnce();
expect(mockOnSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
it('shows a validation error for invalid email format', async () => {
const user = userEvent.setup();
render(<SignupForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText('이메일'), 'not-an-email');
await user.click(screen.getByRole('button', { name: '회원가입' }));
expect(
screen.getByText('올바른 이메일 형식을 입력해주세요.')
).toBeInTheDocument();
});
it('does not call onSubmit when form is empty', async () => {
const user = userEvent.setup();
const mockOnSubmit = vi.fn();
render(<SignupForm onSubmit={mockOnSubmit} />);
await user.click(screen.getByRole('button', { name: '회원가입' }));
expect(mockOnSubmit).not.toHaveBeenCalled();
});
});
If these tests had existed before that refactor, the missing onSubmit connection would have been caught immediately. mockOnSubmit not being called would have lit up red before anything reached production.
The harder challenge was testing components that fetch data. You can't call real APIs in tests, so mocking is required.
// src/components/PostList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { PostList } from './PostList';
const mockPosts = [
{ id: 1, title: '첫 번째 글', slug: 'first-post' },
{ id: 2, title: '두 번째 글', slug: 'second-post' },
];
beforeEach(() => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockPosts,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('PostList', () => {
it('shows a loading spinner while fetching', () => {
render(<PostList />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('renders the post list after data loads', async () => {
render(<PostList />);
await waitFor(() => {
expect(screen.getByText('첫 번째 글')).toBeInTheDocument();
});
expect(screen.getByText('두 번째 글')).toBeInTheDocument();
expect(screen.getAllByRole('article')).toHaveLength(2);
});
it('shows an error message when the API fails', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
render(<PostList />);
await waitFor(() => {
expect(
screen.getByText('데이터를 불러오는 데 실패했습니다.')
).toBeInTheDocument();
});
});
});
waitFor is the key primitive for async tests. It keeps polling the assertion until it passes or times out. Skip it and your test checks the DOM before data loads—instant false failure.
Test what users directly experience: clicking a button opens a modal, submitting a form calls an API, an error condition shows a message. Test business logic like calculations, validation rules, and data transformations. Test edge cases: empty arrays, null inputs, very long strings, zero, negative numbers.
Don't test internal implementation details—which state variables exist, which internal functions get called. These tests break on refactors, turning your test suite into a refactoring obstacle rather than a safety net.
Don't test third-party library behavior. Whether react-hook-form validates correctly isn't your problem to test—that's already covered by the library's own test suite.
Don't test styles. Whether a CSS class name is correct or a color is #ff0000 is not what unit tests are for. Use visual regression tools or just your eyes.
Hooks can only run inside components. renderHook gives you a test harness without writing a throwaway component yourself:
// src/hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('starts at 0 by default', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('increments count by 1', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('accepts an initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
});
Wrap any state-mutating calls in act(). It ensures React processes the state update and re-renders before your assertion runs.
Circus trapeze artists can perform without a net. But one mistake and it's over. With a net, you can try riskier moves. You can fail and get back up.
Code is the same. Without tests, every deploy is a held breath. Every refactor is a gamble. "What if touching this breaks something else?" That anxiety accumulates until nobody wants to touch the codebase. With tests, you can refactor aggressively. If the tests pass, you ship with confidence.
Tests aren't free—they take time to write and maintain. But that cost is small compared to a single production bug, or the erosion of user trust that follows one too many "sorry, we broke sign-up again" incidents.