본문으로 건너뛰기

🧪 테스트 주도 개발(TDD)

📖 정의

TDD(Test-Driven Development, 테스트 주도 개발)는 테스트를 먼저 작성하고 그 테스트를 통과하는 코드를 작성하는 개발 방법론입니다. Red-Green-Refactor 사이클을 반복하며, 코드 품질을 높이고 버그를 줄이며, 리팩토링을 안전하게 만듭니다. 단위 테스트(Unit Test)는 개별 함수나 컴포넌트를 독립적으로 검증하는 테스트입니다.

🎯 비유로 이해하기

설계도 먼저 그리기

TDD를 건축에 비유하면:

전통적인 개발
1. 집 짓기 시작
2. 완성 후 점검
3. 문제 발견 → 큰 수정 필요
4. 비용 증가

TDD
1. 설계도 그리기 (테스트 작성)
2. 설계도대로 짓기 (코드 작성)
3. 검증 (테스트 실행)
4. 개선 (리팩토링)
5. 안전하고 정확함

요리 레시피

테스트 없는 개발
- 재료 대충 넣기
- 맛보기 (버그 발견)
- 다시 만들기 (시간 낭비)

TDD
- 레시피 작성 (테스트)
- 레시피대로 요리 (코드)
- 맛 검증 (테스트 실행)
- 레시피 개선 (리팩토링)

⚙️ 작동 원리

1. TDD 사이클 (Red-Green-Refactor)

🔴 Red (실패)
├─ 테스트 작성
└─ 테스트 실행 → 실패 (코드 없음)

🟢 Green (성공)
├─ 최소한의 코드 작성
└─ 테스트 실행 → 성공

🔵 Refactor (개선)
├─ 코드 개선
└─ 테스트 실행 → 여전히 성공

반복 → 점진적 개선

2. 테스트 피라미드

       🔺
/ \
/ UI \ E2E 테스트 (소수)
/------\ - 전체 시나리오
/ 통합 \ - 느림, 비쌈
/--------\
/ 단위 \ 단위 테스트 (다수)
/----------\ - 개별 함수
------------ - 빠름, 저렴

💡 실제 예시

기본 TDD 예시

// ========== 1. Red: 테스트 작성 (실패) ==========
// calculator.test.js

test('add function adds two numbers', () => {
expect(add(2, 3)).toBe(5);
});

// 실행: FAIL - add is not defined

// ========== 2. Green: 최소 코드 (성공) ==========
// calculator.js

function add(a, b) {
return a + b;
}

module.exports = { add };

// 실행: PASS ✅

// ========== 3. Refactor: 개선 ==========
// 코드가 단순하므로 개선 불필요

// ========== 다음 기능 추가 ==========

// Red: 테스트 추가
test('subtract function subtracts two numbers', () => {
expect(subtract(5, 3)).toBe(2);
});

// Green: 구현
function subtract(a, b) {
return a - b;
}

// Refactor: 필요시 개선

Jest로 단위 테스트

// user.js
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}

isValidEmail() {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(this.email);
}

getDisplayName() {
return this.name.trim() || 'Anonymous';
}
}

module.exports = User;

// user.test.js
const User = require('./user');

describe('User', () => {
// 테스트 전 실행
let user;

beforeEach(() => {
user = new User('김철수', 'kim@example.com');
});

// 테스트 후 정리
afterEach(() => {
user = null;
});

describe('isValidEmail', () => {
test('should return true for valid email', () => {
expect(user.isValidEmail()).toBe(true);
});

test('should return false for invalid email', () => {
user.email = 'invalid-email';
expect(user.isValidEmail()).toBe(false);
});

test('should return false for email without @', () => {
user.email = 'invalid.email.com';
expect(user.isValidEmail()).toBe(false);
});
});

describe('getDisplayName', () => {
test('should return name when name is provided', () => {
expect(user.getDisplayName()).toBe('김철수');
});

test('should trim whitespace from name', () => {
user.name = ' 김철수 ';
expect(user.getDisplayName()).toBe('김철수');
});

test('should return "Anonymous" when name is empty', () => {
user.name = '';
expect(user.getDisplayName()).toBe('Anonymous');
});
});
});

다양한 Matcher

// 기본 매칭
expect(value).toBe(5); // 정확히 같음 (===)
expect(value).toEqual({ a: 1 }); // 깊은 비교
expect(value).not.toBe(0); // 같지 않음

// 참/거짓
expect(value).toBeTruthy(); // truthy
expect(value).toBeFalsy(); // falsy
expect(value).toBeNull(); // null
expect(value).toBeUndefined(); // undefined
expect(value).toBeDefined(); // undefined 아님

// 숫자
expect(value).toBeGreaterThan(3); // > 3
expect(value).toBeGreaterThanOrEqual(3); // >= 3
expect(value).toBeLessThan(5); // < 5
expect(value).toBeCloseTo(0.3); // 부동소수점 근사

// 문자열
expect(str).toMatch(/pattern/); // 정규식 매칭
expect(str).toContain('substring'); // 포함

// 배열/객체
expect(array).toContain('item'); // 배열 포함
expect(array).toHaveLength(3); // 길이
expect(obj).toHaveProperty('key'); // 속성 존재

// 예외
expect(() => {
throw new Error('error');
}).toThrow(); // 예외 발생
expect(() => {
throw new Error('error');
}).toThrow('error'); // 특정 메시지

비동기 테스트

// Promise
test('async function returns data', async () => {
const data = await fetchData();
expect(data).toEqual({ name: 'John' });
});

// 또는
test('async function returns data', () => {
return fetchData().then(data => {
expect(data).toEqual({ name: 'John' });
});
});

// Async/Await with try-catch
test('async function throws error', async () => {
try {
await fetchData();
} catch (error) {
expect(error.message).toBe('Network error');
}
});

// expect.assertions (비동기 검증 보장)
test('async function', async () => {
expect.assertions(1); // 1개의 expect 실행 보장
const data = await fetchData();
expect(data).toBeDefined();
});

목(Mock) 사용

// 함수 목
const mockCallback = jest.fn(x => x * 2);

[1, 2, 3].forEach(mockCallback);

expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledTimes(3);
expect(mockCallback).toHaveBeenCalledWith(1);
expect(mockCallback.mock.results[0].value).toBe(2);

// 모듈 목
// api.js
const axios = require('axios');

async function fetchUser(id) {
const response = await axios.get(`/api/users/${id}`);
return response.data;
}

// api.test.js
jest.mock('axios');

test('fetchUser returns user data', async () => {
const mockUser = { id: 1, name: 'John' };
axios.get.mockResolvedValue({ data: mockUser });

const user = await fetchUser(1);

expect(user).toEqual(mockUser);
expect(axios.get).toHaveBeenCalledWith('/api/users/1');
});

// 타이머 목
jest.useFakeTimers();

test('waits 1 second before calling callback', () => {
const callback = jest.fn();

setTimeout(callback, 1000);

expect(callback).not.toHaveBeenCalled();

// 1초 앞으로
jest.advanceTimersByTime(1000);

expect(callback).toHaveBeenCalled();
});

React 컴포넌트 테스트

// Button.jsx
function Button({ onClick, children, disabled }) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}

// Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';

describe('Button', () => {
test('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});

test('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);

fireEvent.click(screen.getByText('Click me'));

expect(handleClick).toHaveBeenCalledTimes(1);
});

test('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByText('Click me')).toBeDisabled();
});
});

// 더 복잡한 컴포넌트
// TodoList.jsx
function TodoList() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');

const addTodo = () => {
if (input.trim()) {
setTodos([...todos, input]);
setInput('');
}
};

return (
<div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Add todo"
/>
<button onClick={addTodo}>Add</button>
<ul>
{todos.map((todo, index) => (
<li key={index}>{todo}</li>
))}
</ul>
</div>
);
}

// TodoList.test.jsx
test('adds todo when button is clicked', () => {
render(<TodoList />);

const input = screen.getByPlaceholderText('Add todo');
const button = screen.getByText('Add');

// 입력
fireEvent.change(input, { target: { value: 'New todo' } });
fireEvent.click(button);

// 검증
expect(screen.getByText('New todo')).toBeInTheDocument();
expect(input).toHaveValue('');
});

test('does not add empty todo', () => {
render(<TodoList />);

const button = screen.getByText('Add');
fireEvent.click(button);

expect(screen.queryByRole('listitem')).not.toBeInTheDocument();
});

TDD 실전 예시: 계산기

// ========== Step 1: 덧셈 테스트 ==========
// calculator.test.js

describe('Calculator', () => {
test('adds 1 + 2 to equal 3', () => {
const calc = new Calculator();
expect(calc.add(1, 2)).toBe(3);
});
});

// FAIL: Calculator is not defined

// calculator.js
class Calculator {
add(a, b) {
return a + b;
}
}

// PASS ✅

// ========== Step 2: 뺄셈 ==========
test('subtracts 5 - 3 to equal 2', () => {
const calc = new Calculator();
expect(calc.subtract(5, 3)).toBe(2);
});

// FAIL

class Calculator {
add(a, b) {
return a + b;
}

subtract(a, b) {
return a - b;
}
}

// PASS ✅

// ========== Step 3: 곱셈 ==========
test('multiplies 3 * 4 to equal 12', () => {
const calc = new Calculator();
expect(calc.multiply(3, 4)).toBe(12);
});

// FAIL

class Calculator {
// ... 기존 메서드 ...

multiply(a, b) {
return a * b;
}
}

// PASS ✅

// ========== Step 4: 나눗셈 (엣지 케이스) ==========
test('divides 10 / 2 to equal 5', () => {
const calc = new Calculator();
expect(calc.divide(10, 2)).toBe(5);
});

test('throws error when dividing by zero', () => {
const calc = new Calculator();
expect(() => calc.divide(10, 0)).toThrow('Cannot divide by zero');
});

// FAIL

class Calculator {
// ... 기존 메서드 ...

divide(a, b) {
if (b === 0) {
throw new Error('Cannot divide by zero');
}
return a / b;
}
}

// PASS ✅

// ========== Step 5: Refactor ==========
// beforeEach로 중복 제거

describe('Calculator', () => {
let calc;

beforeEach(() => {
calc = new Calculator();
});

test('adds 1 + 2 to equal 3', () => {
expect(calc.add(1, 2)).toBe(3);
});

test('subtracts 5 - 3 to equal 2', () => {
expect(calc.subtract(5, 3)).toBe(2);
});

// ... 나머지 테스트
});

// 모든 테스트 여전히 PASS ✅

🤔 자주 묻는 질문

Q1. TDD의 장점은?

A:

장점:

1. 버그 감소
└─ 테스트가 버그 조기 발견

2. 리팩토링 안전
└─ 테스트가 안전망 역할

3. 코드 품질 향상
└─ 테스트하기 쉬운 코드 = 좋은 설계

4. 문서화
└─ 테스트가 코드 사용법 설명

5. 자신감
└─ 코드 변경 두렵지 않음

6. 디버깅 시간 단축
└─ 문제 위치 빠르게 파악

단점:

1. 초기 시간 투자
└─ 테스트 작성 시간

2. 학습 곡선
└─ TDD 습득 필요

3. 유지보수
└─ 테스트도 유지보수 필요

결론: 장기적으로 이득

Q2. 무엇을 테스트해야 하나요?

A:

// ✅ 테스트해야 할 것

1. 비즈니스 로직
function calculateDiscount(price, discountRate) {
return price * (1 - discountRate);
}

2. 엣지 케이스
test('handles division by zero', () => {
expect(() => divide(10, 0)).toThrow();
});

3. 에러 처리
test('throws error for invalid input', () => {
expect(() => processData(null)).toThrow();
});

4. 공개 API
class User {
login() { /* ... */ } // ✅ 테스트
private validate() { /* ... */ } // ❌ 내부 구현
}

5. 중요한 기능
// 결제, 인증, 데이터 변환 등

// ❌ 테스트 안 해도 되는 것

1. 외부 라이브러리
// React, Lodash 등은 이미 테스트됨

2. 단순 getter/setter
class User {
getName() { return this.name; } // 너무 단순
}

3. 내부 구현 세부사항
// private 메서드, 내부 변수

4. 프레임워크 기능
// Express의 라우팅 자체

Q3. 테스트 커버리지는 얼마나?

A:

# 테스트 커버리지 측정
npm test -- --coverage

# 결과:
# ----------------------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# ----------------------|---------|----------|---------|---------|
# All files | 85.5 | 80.2 | 90.1 | 84.8 |
# calculator.js | 100 | 100 | 100 | 100 |
# user.js | 75.5 | 60.0 | 85.0 | 74.2 |

# 목표:
80%+ 커버리지 // 현실적인 목표
100% 커버리지 // 이상적이지만 비현실적

# 주의:
높은 커버리지 ≠ 좋은 테스트
중요한 것은 의미 있는 테스트!

# 예:
// 100% 커버리지지만 의미 없는 테스트
test('function exists', () => {
expect(myFunction).toBeDefined();
});

// 낮은 커버리지지만 의미 있는 테스트
test('correctly handles user authentication flow', () => {
// 실제 시나리오 테스트
});

Q4. 통합 테스트 vs 단위 테스트?

A:

// 단위 테스트 (Unit Test)
// - 개별 함수/클래스 테스트
// - 빠름, 격리됨

test('add function adds two numbers', () => {
expect(add(2, 3)).toBe(5);
});

// 통합 테스트 (Integration Test)
// - 여러 모듈 함께 테스트
// - 느림, 실제 동작

test('user registration flow', async () => {
// 1. 사용자 생성
const user = await createUser({ name: 'John', email: 'john@example.com' });

// 2. 데이터베이스 확인
const dbUser = await db.users.findOne({ email: 'john@example.com' });
expect(dbUser).toBeDefined();

// 3. 이메일 발송 확인
expect(emailService.sendWelcomeEmail).toHaveBeenCalledWith(user);
});

// E2E 테스트 (End-to-End)
// - 전체 시스템 테스트
// - 가장 느림, 실제 사용자 시나리오

test('complete purchase flow', async () => {
// 1. 로그인
await page.goto('/login');
await page.fill('[name="email"]', 'user@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');

// 2. 상품 선택
await page.goto('/products');
await page.click('.product:first-child');
await page.click('button.add-to-cart');

// 3. 결제
await page.goto('/checkout');
await page.fill('[name="card"]', '4242424242424242');
await page.click('button.pay');

// 4. 확인
await expect(page.locator('.success-message')).toBeVisible();
});

// 비율 (테스트 피라미드)
70% 단위 테스트
20% 통합 테스트
10% E2E 테스트

Q5. TDD는 항상 사용해야 하나요?

A:

✅ TDD가 좋은 경우:

1. 복잡한 비즈니스 로직
└─ 계산, 검증, 변환

2. 명확한 요구사항
└─ 요구사항 → 테스트 → 코드

3. 리팩토링 예정
└─ 테스트가 안전망

4. 팀 프로젝트
└─ 테스트가 문서화

5. 장기 유지보수
└─ 버그 방지

❌ TDD가 불필요한 경우:

1. 프로토타입/POC
└─ 빠른 검증이 우선

2. UI/디자인 탐색
└─ 자주 변경됨

3. 단순 CRUD
└─ 테스트 비용 > 이득

4. 실험적 기능
└─ 요구사항 불명확

5. 일회성 스크립트
└─ 재사용 안 함

// 유연하게 접근:
- 핵심 로직: TDD
- UI: 필요한 것만 테스트
- 프로토타입: 나중에 테스트

🎓 다음 단계

TDD를 이해했다면, 다음을 학습해보세요:

  1. Git이란? (문서 작성 예정) - 테스트와 버전 관리
  2. Node.js란? (문서 작성 예정) - 백엔드 테스트
  3. React란? (문서 작성 예정) - React 컴포넌트 테스트

테스트 도구

// JavaScript
Jest // 가장 인기 (React 기본)
Vitest // Vite 기반 (빠름)
Mocha + Chai // 유연함

// React
React Testing Library // 권장
Enzyme // 레거시

// E2E
Playwright // 현대적 (권장)
Cypress // 인기
Selenium // 레거시

// 코드 커버리지
Istanbul (nyc)
Jest (내장)

// 목(Mock)
jest.fn()
sinon

실습해보기

# 1. 프로젝트 생성
mkdir tdd-practice
cd tdd-practice
npm init -y

# 2. Jest 설치
npm install --save-dev jest

# 3. package.json 수정
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}

# 4. 첫 테스트 작성
# sum.test.js
test('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});

# 5. 테스트 실행
npm test

# 6. 워치 모드 (자동 재실행)
npm run test:watch

🎬 마무리

TDD는 소프트웨어 품질의 기반입니다:

  • Red-Green-Refactor: TDD의 핵심 사이클
  • 단위 테스트: 빠르고 격리된 테스트
  • 테스트 우선: 테스트가 설계를 이끔
  • 지속적 개선: 리팩토링의 안전망

테스트는 미래의 나를 위한 투자입니다! 🧪✨