테스트 전략
핵심 철학
"테스트는 버그를 찾는 것이 아니라, 자신감을 얻는 것이다."
1. 테스트 피라미드
code
/\
/E2E\ ← 적게 (느리고 비쌈)
/------\
/ 통합 \ ← 적당히 (중간)
/----------\
/ 단위 \ ← 많이 (빠르고 저렴)
/-------------\
2. 우선순위
- •핵심 비즈니스 로직 (결제, 주문, 인증)
- •버그가 자주 발생하는 부분
- •리팩토링할 부분 (테스트가 안전망)
- •공개 API / 라이브러리
3. 테스트하지 않아도 되는 것
- •외부 라이브러리 (이미 테스트됨)
- •단순한 getter/setter
- •프레임워크 코드
- •정적 타입으로 검증 가능한 것
단위 테스트 (Unit Test)
테스트할 것
typescript
// ✅ 테스트해야 할 것: 비즈니스 로직
export function calculateDiscount(
price: number,
discountRate: number
): number {
if (price < 0 || discountRate < 0 || discountRate > 100) {
throw new Error('유효하지 않은 입력입니다');
}
return price * (1 - discountRate / 100);
}
// 테스트
describe('calculateDiscount', () => {
it('정상적인 할인 계산', () => {
expect(calculateDiscount(10000, 10)).toBe(9000);
});
it('할인율 0%', () => {
expect(calculateDiscount(10000, 0)).toBe(10000);
});
it('할인율 100%', () => {
expect(calculateDiscount(10000, 100)).toBe(0);
});
it('음수 가격은 에러', () => {
expect(() => calculateDiscount(-10000, 10)).toThrow();
});
it('유효하지 않은 할인율은 에러', () => {
expect(() => calculateDiscount(10000, 101)).toThrow();
});
});
❌ 테스트하지 않아도 되는 것
typescript
// ❌ 단순한 타입 변환 (TypeScript가 보장)
export function getName(user: User): string {
return user.name;
}
// ❌ 외부 라이브러리 (이미 테스트됨)
import dayjs from 'dayjs';
export function formatDate(date: Date): string {
return dayjs(date).format('YYYY-MM-DD');
}
통합 테스트 (Integration Test)
API 엔드포인트 테스트
typescript
// tests/api/users.test.ts
import request from 'supertest';
import app from '@/app';
import { prisma } from '@/lib/prisma';
describe('POST /api/users', () => {
// 테스트 전: DB 초기화
beforeEach(async () => {
await prisma.user.deleteMany();
});
// 테스트 후: 정리
afterAll(async () => {
await prisma.$disconnect();
});
it('사용자 생성 성공', async () => {
const res = await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
name: '홍길동',
password: 'password123',
})
.expect(201);
expect(res.body.user).toMatchObject({
email: 'test@example.com',
name: '홍길동',
});
expect(res.body.user.password).toBeUndefined(); // 비밀번호 노출 안 됨
});
it('중복 이메일은 409 에러', async () => {
// 첫 번째 사용자 생성
await request(app).post('/api/users').send({
email: 'test@example.com',
name: '홍길동',
password: 'password123',
});
// 같은 이메일로 다시 시도
await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
name: '김철수',
password: 'password456',
})
.expect(409);
});
it('유효하지 않은 이메일은 400 에러', async () => {
await request(app)
.post('/api/users')
.send({
email: 'invalid-email',
name: '홍길동',
password: 'password123',
})
.expect(400);
});
});
E2E 테스트
Playwright 예시
typescript
// tests/e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('결제 플로우', () => {
test('상품 구매부터 결제까지', async ({ page }) => {
// 1. 로그인
await page.goto('/login');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
// 2. 상품 페이지
await page.goto('/products/1');
await page.click('button:has-text("장바구니 담기")');
// 3. 장바구니
await page.click('a:has-text("장바구니")');
await expect(page.locator('.cart-item')).toHaveCount(1);
// 4. 주문하기
await page.click('button:has-text("주문하기")');
// 5. 배송지 입력
await page.fill('input[name="recipient"]', '홍길동');
await page.fill('input[name="phone"]', '010-1234-5678');
await page.fill('input[name="address"]', '서울시 강남구');
// 6. 결제
await page.click('button:has-text("결제하기")');
// 7. 완료 확인
await expect(page).toHaveURL(/\/order\/complete/);
await expect(page.locator('h1')).toContainText('주문 완료');
});
});
테스트 더블 (Mock, Stub, Spy)
Mock: 외부 의존성 대체
typescript
// src/services/payment.ts
export async function processPayment(orderId: string) {
const response = await fetch('https://api.payment.com/charge', {
method: 'POST',
body: JSON.stringify({ orderId }),
});
return response.json();
}
// tests/payment.test.ts
import { processPayment } from '@/services/payment';
// fetch Mock
global.fetch = jest.fn();
describe('processPayment', () => {
beforeEach(() => {
(fetch as jest.Mock).mockClear();
});
it('결제 성공', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
json: async () => ({ success: true, transactionId: 'tx123' }),
});
const result = await processPayment('order123');
expect(fetch).toHaveBeenCalledWith(
'https://api.payment.com/charge',
expect.objectContaining({
method: 'POST',
})
);
expect(result.success).toBe(true);
});
it('결제 실패', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(
new Error('Network error')
);
await expect(processPayment('order123')).rejects.toThrow();
});
});
Spy: 함수 호출 감시
typescript
import * as logger from '@/lib/logger';
describe('사용자 생성', () => {
it('생성 시 로그 기록', async () => {
const logSpy = jest.spyOn(logger, 'info');
await createUser({ email: 'test@example.com', name: '홍길동' });
expect(logSpy).toHaveBeenCalledWith(
'User created',
expect.objectContaining({
email: 'test@example.com',
})
);
logSpy.mockRestore();
});
});
React 컴포넌트 테스트
React Testing Library
typescript
// components/LoginForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('로그인 폼 렌더링', () => {
render(<LoginForm />);
expect(screen.getByLabelText('이메일')).toBeInTheDocument();
expect(screen.getByLabelText('비밀번호')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '로그인' })).toBeInTheDocument();
});
it('유효하지 않은 이메일 입력 시 에러', async () => {
render(<LoginForm />);
const emailInput = screen.getByLabelText('이메일');
const submitButton = screen.getByRole('button', { name: '로그인' });
fireEvent.change(emailInput, { target: { value: 'invalid-email' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText('올바른 이메일을 입력하세요')).toBeInTheDocument();
});
});
it('로그인 성공', async () => {
const onLogin = jest.fn();
render(<LoginForm onLogin={onLogin} />);
fireEvent.change(screen.getByLabelText('이메일'), {
target: { value: 'test@example.com' },
});
fireEvent.change(screen.getByLabelText('비밀번호'), {
target: { value: 'password123' },
});
fireEvent.click(screen.getByRole('button', { name: '로그인' }));
await waitFor(() => {
expect(onLogin).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
});
TDD (Test-Driven Development)
Red → Green → Refactor
typescript
// 1. RED: 실패하는 테스트 작성
describe('주문 총액 계산', () => {
it('상품 가격 + 배송비', () => {
const order = {
items: [
{ price: 10000, quantity: 2 },
{ price: 5000, quantity: 1 },
],
deliveryFee: 3000,
};
expect(calculateTotal(order)).toBe(28000);
});
});
// 2. GREEN: 최소한의 코드로 테스트 통과
function calculateTotal(order: Order): number {
const itemsTotal = order.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return itemsTotal + order.deliveryFee;
}
// 3. REFACTOR: 코드 개선
function calculateTotal(order: Order): number {
return calculateItemsTotal(order.items) + order.deliveryFee;
}
function calculateItemsTotal(items: OrderItem[]): number {
return items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
}
테스트 커버리지
의미 있는 커버리지
bash
# Jest 커버리지 npm test -- --coverage # 결과 --------------------|---------|----------|---------|---------| File | % Stmts | % Branch | % Funcs | % Lines | --------------------|---------|----------|---------|---------| All files | 85.23 | 78.45 | 90.12 | 85.67 | services/ | 92.31 | 85.71 | 95.00 | 92.00 | payment.ts | 95.00 | 90.00 | 100.00 | 95.00 | order.ts | 90.00 | 82.00 | 90.00 | 89.00 | --------------------|---------|----------|---------|---------|
⚠️ 100% 커버리지의 함정
typescript
// ❌ 100% 커버리지지만 의미 없는 테스트
it('함수 호출', () => {
calculateDiscount(10000, 10);
// expect 없음!
});
// ✅ 의미 있는 테스트
it('할인 계산', () => {
const result = calculateDiscount(10000, 10);
expect(result).toBe(9000);
});
// ✅ 엣지 케이스 테스트
it('음수 입력', () => {
expect(() => calculateDiscount(-10000, 10)).toThrow();
});
실전 팁
1. AAA 패턴
typescript
describe('사용자 생성', () => {
it('이메일 중복 시 에러', async () => {
// Arrange: 준비
await createUser({ email: 'test@example.com', name: '홍길동' });
// Act: 실행
const promise = createUser({ email: 'test@example.com', name: '김철수' });
// Assert: 검증
await expect(promise).rejects.toThrow('이미 존재하는 이메일입니다');
});
});
2. 테스트 격리
typescript
// ❌ 테스트 간 의존성
let user: User;
it('사용자 생성', async () => {
user = await createUser({ email: 'test@example.com' });
});
it('사용자 조회', async () => {
const found = await getUser(user.id); // 이전 테스트에 의존
expect(found).toBeDefined();
});
// ✅ 각 테스트 독립적
describe('사용자 조회', () => {
it('존재하는 사용자', async () => {
const user = await createUser({ email: 'test@example.com' });
const found = await getUser(user.id);
expect(found).toBeDefined();
});
});
3. 의미 있는 테스트 이름
typescript
// ❌ 모호한 이름
it('테스트1', () => { ... });
it('작동함', () => { ... });
// ✅ 명확한 이름
it('유효하지 않은 이메일 입력 시 에러를 반환한다', () => { ... });
it('재고가 부족하면 주문을 거부한다', () => { ... });
테스트 환경 설정
Jest 설정
javascript
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: ['**/__tests__/**/*.ts', '**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.test.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
package.json 스크립트
json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:unit": "jest --testMatch='**/*.test.ts'",
"test:e2e": "playwright test"
}
}
체크리스트
테스트 작성 전
- • 이 코드는 테스트할 가치가 있는가?
- • 핵심 비즈니스 로직인가?
- • 자주 버그가 발생하는 부분인가?
테스트 작성 시
- • 테스트 이름이 명확한가?
- • AAA 패턴을 따르는가?
- • 하나의 테스트에 하나의 검증만?
- • 엣지 케이스를 고려했는가?
PR 전
- • 모든 테스트 통과?
- • 새 기능에 테스트 추가?
- • 커버리지 감소하지 않음?
마무리 원칙
"좋은 테스트는 문서다. 코드가 무엇을 하는지 설명한다."
- •의미 있는 테스트: 커버리지보다 품질
- •빠른 피드백: 단위 테스트 중심
- •신뢰할 수 있는 테스트: Flaky test 제거
- •유지보수 가능한 테스트: 코드처럼 리팩토링
- •자신감: 리팩토링과 배포의 안전망