
TDD vs BDD: 테스트 주도 개발 전략 비교
TDD와 BDD는 둘 다 '테스트 먼저'라는 철학을 공유하지만, 초점과 사용 방식이 다르다. Red-Green-Refactor 사이클부터 Gherkin 문법까지, 실전 TypeScript 예제로 비교해봤다.

TDD와 BDD는 둘 다 '테스트 먼저'라는 철학을 공유하지만, 초점과 사용 방식이 다르다. Red-Green-Refactor 사이클부터 Gherkin 문법까지, 실전 TypeScript 예제로 비교해봤다.
코드를 먼저 짜고 테스트하는 게 아닙니다. 테스트를 먼저 짜고, 그걸 통과하기 위해 코딩하는 것. 순서를 뒤집으면 버그가 사라집니다.

코드 푸시하면 로봇이 테스트하고(CI), 로봇이 배포합니다(CD). '내 컴퓨터에서는 잘 됐는데'라는 변명은 이제 안 통합니다. 자동화 파이프라인으로 하루 100번 배포하기.

백엔드 API가 준비되지 않아 프론트엔드 개발이 멈추는 문제를 MSW(Mock Service Worker)로 해결했다. 네트워크 레벨 API 모킹의 원리와 실전 패턴.

유닛 테스트가 다 통과해도 배포하면 에러가 나는 이유는 뭘까요? 사용자가 실제로 사용하는 흐름 그대로를 검증하는 E2E(End-to-End) 테스트가 필요합니다. Cypress와 Playwright의 장단점 비교, 깨지기 쉬운(Flaky) 테스트를 방지하는 전략, 그리고 테스트 피라미드 속 E2E의 역할을 정리합니다.

"테스트 먼저 짜면 개발 속도 느려지지 않아요?" 이 질문을 들을 때마다 내가 TDD를 처음 배우던 시절이 생각난다. 당시엔 나도 그렇게 생각했다. 코드 짜기도 바쁜데 테스트까지 먼저 짜라고? 말이 안 되는 것 같았다.
근데 실제로 해보니 달랐다. 테스트를 먼저 짜면 "내가 뭘 만들려고 하는가"를 코드 짜기 전에 명확히 하게 된다. 그 명확함이 결국 더 빠르게, 더 올바르게 만드는 데 도움이 됐다.
이 글은 TDD와 BDD — 두 가지 테스트 주도 전략을 비교하고, 각각 언제 쓰는 게 맞는지 TypeScript 예제로 살펴본다.
TDD(Test-Driven Development)는 1999년 켄트 벡이 익스트림 프로그래밍(XP)의 일부로 체계화한 개발 방법론이다. 핵심은 딱 세 단계다:
Red → Green → Refactor
// 1. 아직 존재하지 않는 함수의 테스트를 먼저 작성
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from './discount';
describe('calculateDiscount', () => {
it('10만원 이상 구매 시 10% 할인', () => {
expect(calculateDiscount(100000)).toBe(90000);
});
it('10만원 미만은 할인 없음', () => {
expect(calculateDiscount(50000)).toBe(50000);
});
it('0원은 0원 반환', () => {
expect(calculateDiscount(0)).toBe(0);
});
it('음수 금액은 에러', () => {
expect(() => calculateDiscount(-1000)).toThrow('금액은 0 이상이어야 합니다');
});
});
이 테스트를 실행하면 당연히 실패한다. calculateDiscount 함수 자체가 없으니까. 이게 Red 단계다.
// 2. 테스트가 통과할 최소한의 코드 작성
export function calculateDiscount(amount: number): number {
if (amount < 0) {
throw new Error('금액은 0 이상이어야 합니다');
}
if (amount >= 100000) {
return amount * 0.9;
}
return amount;
}
이제 테스트가 통과한다. 코드가 완벽하지 않아도 된다. 일단 초록불 켜는 게 목표다. 이게 Green 단계다.
// 3. 테스트가 깨지지 않으면서 코드 품질 개선
const DISCOUNT_THRESHOLD = 100_000;
const DISCOUNT_RATE = 0.1;
export function calculateDiscount(amount: number): number {
if (amount < 0) {
throw new Error('금액은 0 이상이어야 합니다');
}
const discountAmount = amount >= DISCOUNT_THRESHOLD
? amount * DISCOUNT_RATE
: 0;
return amount - discountAmount;
}
매직 넘버를 상수로 뽑고, 할인 로직을 명확하게 표현했다. 테스트는 여전히 통과한다. 이게 Refactor 단계다.
이 사이클을 아주 짧은 주기로 (5~15분) 반복하는 게 TDD다.
TDD의 진짜 가치는 빠른 버그 발견이 아니다. 설계를 강제로 개선시킨다는 데 있다.
테스트가 어렵게 느껴지면, 그건 코드 설계에 문제가 있다는 신호다.
// 테스트하기 어려운 코드 예시
class OrderService {
async processOrder(orderId: string) {
// DB에서 직접 가져오고
const order = await db.query(`SELECT * FROM orders WHERE id = '${orderId}'`);
// 외부 API 직접 호출하고
const payment = await fetch('https://payment.api/charge', {
method: 'POST',
body: JSON.stringify({ amount: order.total })
});
// 이메일 직접 발송하고
await sendEmail(order.userEmail, '주문 완료', '...');
return { success: true };
}
}
이 클래스를 테스트하려면 실제 DB, 실제 결제 API, 실제 이메일 서버가 필요하다. 테스트가 불가능에 가깝다.
TDD로 이 코드를 처음부터 짰다면, 의존성 주입을 자연스럽게 쓰게 됐을 것이다:
// TDD로 설계하면 자연스럽게 나오는 구조
interface OrderRepository {
findById(id: string): Promise<Order>;
}
interface PaymentGateway {
charge(amount: number): Promise<PaymentResult>;
}
interface EmailService {
send(to: string, subject: string, body: string): Promise<void>;
}
class OrderService {
constructor(
private orderRepo: OrderRepository,
private paymentGateway: PaymentGateway,
private emailService: EmailService
) {}
async processOrder(orderId: string) {
const order = await this.orderRepo.findById(orderId);
const payment = await this.paymentGateway.charge(order.total);
await this.emailService.send(order.userEmail, '주문 완료', '...');
return { success: true };
}
}
이제 각 의존성을 mock으로 바꿔서 쉽게 테스트할 수 있다:
describe('OrderService', () => {
it('주문 처리 성공 시 결제 후 이메일 발송', async () => {
// Arrange
const mockOrder = { id: '123', total: 50000, userEmail: 'test@example.com' };
const mockOrderRepo = { findById: vi.fn().mockResolvedValue(mockOrder) };
const mockPayment = { charge: vi.fn().mockResolvedValue({ success: true }) };
const mockEmail = { send: vi.fn().mockResolvedValue(undefined) };
const service = new OrderService(mockOrderRepo, mockPayment, mockEmail);
// Act
await service.processOrder('123');
// Assert
expect(mockPayment.charge).toHaveBeenCalledWith(50000);
expect(mockEmail.send).toHaveBeenCalledWith(
'test@example.com',
'주문 완료',
expect.any(String)
);
});
});
TDD는 테스트 작성 방법론이 아니라 설계 방법론이다. 이게 핵심이다.
BDD(Behavior-Driven Development)는 2006년 댄 노스가 TDD의 진화형으로 제안했다. TDD의 "어떻게 테스트하나"에서 "어떤 동작을 테스트하나"로 초점을 옮겼다.
BDD의 핵심 아이디어: 개발자, QA, 비즈니스 팀이 모두 이해할 수 있는 언어로 동작을 기술한다.
BDD에서 가장 유명한 기술 방식은 Gherkin이다:
Feature: 할인 계산
할인 정책에 따라 구매 금액에서 할인을 적용한다
Scenario: 10만원 이상 구매 시 할인 적용
Given 구매 금액이 100,000원인 경우
When 할인을 계산하면
Then 할인된 금액은 90,000원이어야 한다
Scenario: 10만원 미만 구매 시 할인 없음
Given 구매 금액이 50,000원인 경우
When 할인을 계산하면
Then 원래 금액 50,000원이 반환된다
Scenario: 음수 금액은 에러
Given 구매 금액이 -1,000원인 경우
When 할인을 계산하려고 하면
Then "금액은 0 이상이어야 합니다" 에러가 발생한다
이걸 개발자가 아닌 기획자나 고객이 읽어도 이해할 수 있다.
TypeScript에서 BDD를 구현할 때 가장 많이 쓰는 건 Jest/Vitest + Cucumber.js 조합이다. 근데 실무에서는 Gherkin 파일 없이 BDD 스타일로 describe/it을 쓰는 경우도 많다:
// BDD 스타일 테스트 (Gherkin 없이)
describe('할인 계산 기능', () => {
describe('사용자가 10만원 이상 구매할 때', () => {
it('10% 할인이 적용된다', () => {
// Given
const purchaseAmount = 100_000;
// When
const discountedPrice = calculateDiscount(purchaseAmount);
// Then
expect(discountedPrice).toBe(90_000);
});
});
describe('사용자가 10만원 미만 구매할 때', () => {
it('할인이 적용되지 않는다', () => {
const purchaseAmount = 50_000;
const discountedPrice = calculateDiscount(purchaseAmount);
expect(discountedPrice).toBe(50_000);
});
});
});
진짜 BDD를 원한다면 Cucumber.js를 써서 Gherkin 파일과 스텝 정의를 연결할 수 있다:
// features/discount.feature
/*
Feature: 할인 계산
Scenario: 10만원 이상 구매 시 할인
Given 구매 금액이 100000원
When 할인을 계산하면
Then 결과는 90000원
*/
// features/step_definitions/discount.steps.ts
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import { calculateDiscount } from '../../src/discount';
let purchaseAmount: number;
let result: number;
Given('구매 금액이 {int}원', (amount: number) => {
purchaseAmount = amount;
});
When('할인을 계산하면', () => {
result = calculateDiscount(purchaseAmount);
});
Then('결과는 {int}원', (expected: number) => {
expect(result).to.equal(expected);
});
| 항목 | TDD | BDD |
|---|---|---|
| 초점 | 구현 방법 (어떻게) | 동작 명세 (무엇을) |
| 언어 | 기술적 언어 | 비즈니스 언어 |
| 대상 독자 | 개발자 | 개발자 + 비즈니스 |
| 테스트 단위 | 함수/메서드 | 기능/시나리오 |
| 도구 | Jest, Vitest | Cucumber, Jest/Vitest |
| 진입 장벽 | 낮음 | 중간 |
| 팀 커뮤니케이션 | 개발자 위주 | 전체 팀 |
// 1. 알고리즘/유틸리티 함수 구현
// 입력/출력이 명확하고 로직이 복잡한 경우
describe('parsePhoneNumber', () => {
it('010-1234-5678 형식 파싱', () => {
expect(parsePhoneNumber('010-1234-5678')).toEqual({
countryCode: '+82',
number: '01012345678'
});
});
it('국제번호 +82-10-1234-5678 파싱', () => {
expect(parsePhoneNumber('+82-10-1234-5678')).toEqual({
countryCode: '+82',
number: '01012345678'
});
});
});
// 2. 데이터 변환 로직
// 3. 비즈니스 규칙 구현
// 4. 버그 수정 (버그 재현 테스트 먼저)
TDD는 "이 함수가 올바르게 동작하는가"를 확인하는 데 강하다.
# 비즈니스 팀과 합의가 필요한 복잡한 워크플로우
Feature: 구독 관리
Scenario: 무료 체험 종료 후 자동 결제
Given 사용자가 14일 무료 체험 중이다
And 결제 수단이 등록되어 있다
When 무료 체험 기간이 종료된다
Then 자동으로 기본 플랜 결제가 진행된다
And 사용자에게 결제 완료 이메일이 발송된다
Scenario: 결제 수단 없이 무료 체험 종료
Given 사용자가 14일 무료 체험 중이다
And 결제 수단이 등록되지 않았다
When 무료 체험 기간이 종료된다
Then 계정이 일시 정지된다
And 사용자에게 결제 수단 등록 알림이 발송된다
BDD는 비즈니스 팀이 직접 시나리오를 검증할 수 있어서, 요구사항 오해를 줄이는 데 강하다.
맞다, 초반엔 더 많이 쓴다. 근데 나중에 디버깅하는 시간, 버그 고치는 시간을 생각하면 전체 개발 시간은 오히려 줄어드는 경우가 많다.
연구에 따르면 TDD를 적용했을 때 초기 개발 시간은 1535% 증가하지만, 버그 수는 4080% 감소한다. 버그 수정은 기능 개발보다 훨씬 비싸다.
// 커버리지는 있지만 의미없는 테스트
it('함수가 실행된다', () => {
expect(() => processOrder('123')).not.toThrow();
// 실행만 됐다는 걸 테스트, 동작을 테스트하지 않음
});
100% 커버리지보다 의미 있는 케이스를 테스트하는 게 훨씬 중요하다.
BDD는 철학이지 도구가 아니다. describe/it을 BDD 스타일로 써도 충분하다:
// 이것도 BDD다
describe('Given 로그인한 사용자가', () => {
describe('When 관리자 페이지에 접근하면', () => {
it('Then 권한 없음 에러가 발생한다', () => {
// ...
});
});
});
섞어쓰는 게 오히려 자연스럽다:
TDD로 간단한 장바구니를 처음부터 만들어보자.
// 1단계: Red — 가장 간단한 테스트부터
describe('ShoppingCart', () => {
it('빈 장바구니 생성', () => {
const cart = new ShoppingCart();
expect(cart.items).toHaveLength(0);
expect(cart.total).toBe(0);
});
});
// → ShoppingCart 클래스가 없으니 실패. Green으로.
// 2단계: Green — 최소한의 구현
class ShoppingCart {
items: CartItem[] = [];
get total() {
return 0;
}
}
// → 테스트 통과. 다음 테스트.
// 3단계: 상품 추가 테스트 추가
it('상품을 장바구니에 추가', () => {
const cart = new ShoppingCart();
cart.add({ id: '1', name: '노트북', price: 1_000_000, quantity: 1 });
expect(cart.items).toHaveLength(1);
expect(cart.total).toBe(1_000_000);
});
// → 실패. Green으로.
class ShoppingCart {
items: CartItem[] = [];
add(item: CartItem) {
this.items.push(item);
}
get total() {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
// 4단계: 같은 상품 두 번 추가 시 수량 증가 테스트
it('같은 상품 추가 시 수량 증가', () => {
const cart = new ShoppingCart();
const laptop = { id: '1', name: '노트북', price: 1_000_000, quantity: 1 };
cart.add(laptop);
cart.add(laptop);
expect(cart.items).toHaveLength(1); // 2개 아닌 1개 아이템
expect(cart.items[0].quantity).toBe(2);
expect(cart.total).toBe(2_000_000);
});
// → 실패. Green + Refactor.
class ShoppingCart {
items: CartItem[] = [];
add(item: CartItem) {
const existing = this.items.find(i => i.id === item.id);
if (existing) {
existing.quantity += item.quantity;
} else {
this.items.push({ ...item });
}
}
get total() {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
이렇게 작은 사이클을 반복하면서 기능이 쌓인다. 각 사이클이 끝날 때마다 코드는 동작하고, 테스트로 보호되어 있다.
TDD와 BDD는 경쟁 관계가 아니다. TDD는 구현 수준에서 설계를 돕고, BDD는 기능 수준에서 요구사항을 명확히 한다.
처음 시작한다면 TDD부터 익히는 게 낫다. Red-Green-Refactor 사이클을 몸에 익히고 나면, BDD는 그 위에 자연스럽게 얹힌다.
중요한 건 도구가 아니라 습관이다. 구현하기 전에 "이게 어떻게 동작해야 하지?"를 먼저 코드로 쓰는 습관. 그게 TDD든 BDD든, 그 습관이 코드를 바꾼다.