
테스트 코드... 알아야 한다는 건 알겠는데 어디서부터 시작해야 할지 막막하죠?
이 글은 테스트가 완전히 처음인 분을 위해 "왜 써야 하는지"부터 "어떻게 쓰는지"까지 차근차근 정리했습니다.
개발하다 보면 이런 경험 한 번쯤 있으셨을 겁니다.
"분명 이 버튼 고쳤는데... 저쪽 기능이 왜 갑자기 안 되지?"
코드를 수정할 때마다 모든 기능을 손으로 직접 눌러보며 확인하는 건 시간도 오래 걸리고, 실수도 잦습니다.
테스트 코드는 이 과정을 자동화합니다.
테스트 코드가 없을 때
수정 → 수동 확인 → 배포 → 버그 발견 → 야근 😭
테스트 코드가 있을 때
수정 → 테스트 실행(자동) → 문제 즉시 발견 → 안심 배포 😊
테스트는 크게 세 가지 층으로 나뉩니다.
/\
/ \
/ E2E \ ← 적게, 핵심 시나리오만
/────────\
/통합 테스트\ ← 중간 정도
/────────────\
/ 단위 테스트 \ ← 많이, 빠르게
/─────────────────\
| 종류 | 테스트 대상 | 속도 | 비용 | 신뢰도 |
|---|---|---|---|---|
| 단위(Unit) | 함수, 컴포넌트 하나 | 매우 빠름 | 낮음 | 낮음 |
| 통합(Integration) | 여러 컴포넌트 조합 | 보통 | 중간 | 중간 |
| E2E(End-to-End) | 실제 브라우저 전체 흐름 | 느림 | 높음 | 높음 |
피라미드 아래로 갈수록 많이, 빠르게 / 위로 갈수록 적게, 느리게
대부분의 테스트는 단위 + 통합에 집중하는 것이 효율적입니다.
함수 하나, 컴포넌트 하나를 독립적으로 테스트합니다.
다른 것에 의존하지 않고 해당 단위만 검증합니다.
npm install -D vitest
<# 또는
npm install -D jest @types/jest
// utils/formatPrice.js
export function formatPrice(price) {
if (price < 0) throw new Error('가격은 0 이상이어야 합니다');
return `${price.toLocaleString()}원`;
}
// utils/formatPrice.test.js
import { describe, it, expect } from 'vitest';
import { formatPrice } from './formatPrice';
describe('formatPrice', () => {
it('숫자를 한국 원화 형식으로 변환한다', () => {
expect(formatPrice(10000)).toBe('10,000원');
});
it('0원도 정상 처리한다', () => {
expect(formatPrice(0)).toBe('0원');
});
it('음수면 에러를 던진다', () => {
expect(() => formatPrice(-1)).toThrow('가격은 0 이상이어야 합니다');
});
});
좋은 테스트는 세 단계로 구성됩니다.
it('설명', () => {
// Arrange: 준비
const input = 10000;
// Act: 실행
const result = formatPrice(input);
// Assert: 검증
expect(result).toBe('10,000원');
});
React 컴포넌트가 올바르게 렌더링되고 동작하는지 테스트합니다.
"구현 방식이 아닌 사용자 관점에서 테스트하라"는 철학을 가진 라이브러리입니다.
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
// components/Button.jsx
export function Button({ onClick, disabled, children }) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
// components/Button.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button 컴포넌트', () => {
it('텍스트가 올바르게 렌더링된다', () => {
render(<Button>클릭하세요</Button>);
expect(screen.getByText('클릭하세요')).toBeInTheDocument();
});
it('클릭 시 onClick이 호출된다', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>클릭</Button>);
await userEvent.click(screen.getByText('클릭'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('disabled 상태에서는 클릭이 동작하지 않는다', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick} disabled>클릭</Button>);
await userEvent.click(screen.getByText('클릭'));
expect(handleClick).not.toHaveBeenCalled();
});
});
| 메서드 | 사용 상황 | 예시 |
|---|---|---|
getByText | 텍스트로 요소 찾기 | getByText('제출') |
getByRole | 역할로 찾기 (권장) | getByRole('button', { name: '제출' }) |
getByLabelText | label과 연결된 input | getByLabelText('이메일') |
getByPlaceholderText | placeholder로 찾기 | getByPlaceholderText('이메일 입력') |
getByTestId | data-testid 속성 | getByTestId('submit-btn') |
getByRole을 우선적으로 사용하는 것이 접근성 측면에서도 좋습니다.
// getBy: 없으면 에러 → 반드시 있어야 하는 요소
screen.getByText('제목')
// queryBy: 없으면 null → 없어야 하는 요소 검증 시
expect(screen.queryByText('에러 메시지')).not.toBeInTheDocument()
// findBy: 비동기 대기 → 나중에 나타나는 요소
const element = await screen.findByText('로딩 완료')
API 호출처럼 비동기 동작을 테스트할 때는 모킹(Mocking)이 필요합니다.
실제 네트워크 요청을 가로채서 가짜 응답을 반환합니다.
npm install -D msw
// mocks/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: '홍길동' },
{ id: 2, name: '김철수' },
]);
}),
http.post('/api/login', async ({ request }) => {
const { email } = await request.json();
if (email === 'test@test.com') {
return HttpResponse.json({ token: 'fake-token' });
}
return new HttpResponse(null, { status: 401 });
}),
];
// components/UserList.test.jsx
import { render, screen } from '@testing-library/react';
import { server } from '../mocks/server';
import { UserList } from './UserList';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('유저 목록을 불러와서 표시한다', async () => {
render(<UserList />);
expect(screen.getByText('로딩 중...')).toBeInTheDocument();
expect(await screen.findByText('홍길동')).toBeInTheDocument();
expect(screen.getByText('김철수')).toBeInTheDocument();
});
여러 컴포넌트가 함께 잘 동작하는지 테스트합니다.
실제 사용자 시나리오에 가깝게 작성합니다.
// LoginForm.test.jsx
describe('로그인 폼', () => {
it('이메일과 비밀번호를 입력하고 제출하면 로그인된다', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('이메일'), 'test@test.com');
await user.type(screen.getByLabelText('비밀번호'), 'password123');
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(await screen.findByText('환영합니다!')).toBeInTheDocument();
});
it('이메일을 입력하지 않으면 에러 메시지가 표시된다', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(screen.getByText('이메일을 입력해주세요')).toBeInTheDocument();
});
});
실제 브라우저에서 실제 사용자처럼 전체 흐름을 테스트합니다.
npm install -D @playwright/test
npx playwright install
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test('로그인 후 대시보드로 이동한다', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.fill('[name="email"]', 'test@test.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('환영합니다')).toBeVisible();
});
test('잘못된 비밀번호로 로그인 실패', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.fill('[name="email"]', 'test@test.com');
await page.fill('[name="password"]', 'wrong-password');
await page.click('button[type="submit"]');
await expect(page.getByText('이메일 또는 비밀번호가 잘못되었습니다')).toBeVisible();
});
npx playwright test # 전체 실행
npx playwright test --ui # UI 모드 (시각적 확인)
npx playwright test --project=chromium # 특정 브라우저만
테스트에서 외부 의존성(API, 모듈, 함수)을 가짜로 교체하는 기법입니다.
// vi.fn() — 호출 여부, 인자, 반환값을 추적하는 가짜 함수
const mockFn = vi.fn();
mockFn('hello');
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenCalledTimes(1);
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: '홍길동' }),
}));
vi.useFakeTimers();
setTimeout(() => console.log('3초 후'), 3000);
vi.advanceTimersByTime(3000); // 시간을 앞으로 돌림
vi.useRealTimers();
// ❌ 내부 구현(state)에 의존
expect(component.state.isLoading).toBe(false);
// ✅ 사용자가 보는 결과에 집중
expect(screen.queryByText('로딩 중...')).not.toBeInTheDocument();
// ❌ 테스트 순서에 의존
let user;
it('유저를 생성한다', () => { user = createUser(); });
it('유저를 수정한다', () => { updateUser(user); });
// ✅ 각 테스트가 자체적으로 준비
it('유저를 수정한다', () => {
const user = createUser();
updateUser(user);
});
// ❌
it('버튼 테스트', () => { ... });
// ✅ 조건 + 기대 결과
it('장바구니가 비어있을 때 결제 버튼이 비활성화된다', () => { ... });
// ❌ 여러 관심사가 섞임
it('폼 테스트', () => { /* 렌더링, 입력, 제출, 에러 모두 검증 */ });
// ✅ 관심사 분리
it('초기 렌더링 시 제출 버튼이 비활성화된다', () => { ... });
it('필수 필드 입력 후 제출 버튼이 활성화된다', () => { ... });
it('제출 성공 시 성공 메시지가 표시된다', () => { ... });
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom msw
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
});
// src/test/setup.ts
import '@testing-library/jest-dom';
// package.json scripts
{
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
테스트가 코드를 얼마나 실행했는지 측정합니다.
npm run test:coverage
----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
----------|---------|----------|---------|---------|
Button.tsx| 100 | 100 | 100 | 100 |
Form.tsx | 80 | 75 | 90 | 80 |
----------|---------|----------|---------|---------|
⚠️ 커버리지 100%가 목표가 아닙니다!
핵심 비즈니스 로직 70~80% 커버가 현실적인 목표입니다.
커버리지가 높아도 잘못 작성된 테스트는 의미가 없습니다.
1단계: 순수 함수부터 시작
└── 유틸 함수, 계산 로직 → 가장 쉽고 빠름
2단계: 단순 컴포넌트 테스트
└── props에 따라 렌더링이 달라지는 컴포넌트
3단계: 사용자 인터랙션 테스트
└── 클릭, 입력, 폼 제출
4단계: 비동기 + API 모킹
└── MSW로 API 가짜 응답 처리
5단계: 통합 테스트
└── 실제 사용자 시나리오
6단계: E2E 테스트 (선택)
└── 핵심 Critical Path만
| 용도 | 도구 | 특징 |
|---|---|---|
| 테스트 러너 | Vitest | Vite 기반, 빠름, Jest 호환 |
| 테스트 러너 | Jest | 가장 널리 쓰임 |
| 컴포넌트 테스트 | React Testing Library | 사용자 관점 테스트 |
| API 모킹 | MSW | 네트워크 레벨 모킹 |
| E2E | Playwright | 크로스 브라우저, 강력함 |
| E2E | Cypress | 시각적 UI, 쉬운 디버깅 |
| 컴포넌트 격리 개발 | Storybook | UI 컴포넌트 격리 환경 |
처음에는 테스트 코드 작성이 "기능 개발 시간을 빼앗는 것" 처럼 느껴질 수 있습니다.
하지만 프로젝트가 커질수록, 팀원이 늘어날수록 테스트는 안전망이 되어줍니다.
처음부터 완벽한 테스트를 작성하려 하지 마세요.
오늘 작성한 함수 하나, 컴포넌트 하나부터 테스트해보는 것으로 충분합니다. 🚀
"나중에 테스트 작성할게요" — 그 나중은 오지 않습니다.
📚 참고