본문으로 건너뛰기
Advertisement

TDD — 테스트 주도 개발

**TDD(Test-Driven Development)**는 코드를 작성하기 전에 테스트를 먼저 작성하는 개발 방법론입니다. Red → Green → Refactor 사이클을 반복합니다.


Red-Green-Refactor 사이클

🔴 Red    → 실패하는 테스트 작성
🟢 Green → 테스트를 통과하는 최소한의 코드 작성
🔵 Refactor → 중복 제거, 코드 개선 (테스트는 계속 통과)
# 예: 은행 계좌 구현 — TDD 단계별 진행

# ===== 1단계: Red =====
# 아직 BankAccount 클래스가 없어서 실패
def test_initial_balance():
account = BankAccount() # NameError
assert account.balance == 0


# ===== 2단계: Green =====
# 최소한의 코드로 테스트 통과
class BankAccount:
def __init__(self):
self.balance = 0


def test_initial_balance():
account = BankAccount()
assert account.balance == 0 # ✅ 통과


# ===== 3단계: 다음 Red =====
def test_deposit():
account = BankAccount()
account.deposit(1000)
assert account.balance == 1000 # 실패 (deposit 없음)


# ===== 4단계: Green =====
class BankAccount:
def __init__(self):
self.balance = 0

def deposit(self, amount: int) -> None:
self.balance += amount


# ===== 5단계: 다음 Red =====
def test_withdraw():
account = BankAccount()
account.deposit(5000)
account.withdraw(2000)
assert account.balance == 3000 # 실패


def test_withdraw_insufficient():
account = BankAccount()
account.deposit(1000)
with pytest.raises(ValueError, match="잔액 부족"):
account.withdraw(2000) # 실패


# ===== 6단계: Green + Refactor =====
import pytest


class BankAccount:
def __init__(self):
self.balance = 0

def deposit(self, amount: int) -> None:
if amount <= 0:
raise ValueError("입금액은 양수여야 합니다")
self.balance += amount

def withdraw(self, amount: int) -> None:
if amount > self.balance:
raise ValueError("잔액 부족")
self.balance -= amount

실전 TDD: 쇼핑 카트 구현

import pytest


# ===== Step 1: 빈 카트 =====
def test_empty_cart():
cart = ShoppingCart()
assert cart.total == 0
assert cart.item_count == 0


class ShoppingCart:
def __init__(self):
self._items = []

@property
def total(self) -> float:
return sum(item["price"] * item["qty"] for item in self._items)

@property
def item_count(self) -> int:
return sum(item["qty"] for item in self._items)


# ===== Step 2: 아이템 추가 =====
def test_add_item():
cart = ShoppingCart()
cart.add_item("Python 책", 35000, qty=1)
assert cart.total == 35000
assert cart.item_count == 1


def test_add_multiple_items():
cart = ShoppingCart()
cart.add_item("Python 책", 35000)
cart.add_item("마우스", 50000)
assert cart.total == 85000
assert cart.item_count == 2


class ShoppingCart:
def __init__(self):
self._items = []

def add_item(self, name: str, price: float, qty: int = 1) -> None:
for item in self._items:
if item["name"] == name:
item["qty"] += qty
return
self._items.append({"name": name, "price": price, "qty": qty})

@property
def total(self) -> float:
return sum(item["price"] * item["qty"] for item in self._items)

@property
def item_count(self) -> int:
return sum(item["qty"] for item in self._items)


# ===== Step 3: 할인 적용 =====
def test_apply_discount():
cart = ShoppingCart()
cart.add_item("Python 책", 35000)
cart.apply_discount(10) # 10% 할인
assert cart.total == pytest.approx(31500)


def test_invalid_discount():
cart = ShoppingCart()
with pytest.raises(ValueError, match="할인율"):
cart.apply_discount(110) # 100% 초과 불가


class ShoppingCart:
def __init__(self):
self._items = []
self._discount = 0

def add_item(self, name: str, price: float, qty: int = 1) -> None:
for item in self._items:
if item["name"] == name:
item["qty"] += qty
return
self._items.append({"name": name, "price": price, "qty": qty})

def apply_discount(self, percent: float) -> None:
if not (0 <= percent <= 100):
raise ValueError(f"할인율은 0~100 사이여야 합니다: {percent}")
self._discount = percent

@property
def total(self) -> float:
subtotal = sum(item["price"] * item["qty"] for item in self._items)
return subtotal * (1 - self._discount / 100)

@property
def item_count(self) -> int:
return sum(item["qty"] for item in self._items)

pytest-cov — 코드 커버리지

# pip install pytest-cov

# 커버리지 측정
pytest --cov=src tests/
pytest --cov=src --cov-report=term-missing tests/
pytest --cov=src --cov-report=html tests/ # htmlcov/ 디렉토리 생성

# 최소 커버리지 강제 (CI에서 유용)
pytest --cov=src --cov-fail-under=80 tests/
# 커버리지 분석 예시

# calculator.py
def calculate(op: str, a: float, b: float) -> float:
if op == "add":
return a + b
elif op == "sub":
return a - b
elif op == "mul":
return a * b
elif op == "div":
if b == 0:
raise ZeroDivisionError
return a / b
else:
raise ValueError(f"알 수 없는 연산: {op}")


# test_calculator.py — 커버리지 100% 달성하려면?
import pytest


def test_add():
assert calculate("add", 1, 2) == 3 # "add" 분기 커버


def test_sub():
assert calculate("sub", 5, 3) == 2 # "sub" 분기 커버


def test_mul():
assert calculate("mul", 4, 3) == 12 # "mul" 분기 커버


def test_div():
assert calculate("div", 10, 2) == 5.0 # "div" 분기 커버


def test_div_zero():
with pytest.raises(ZeroDivisionError):
calculate("div", 1, 0) # b==0 분기 커버


def test_unknown_op():
with pytest.raises(ValueError):
calculate("mod", 5, 3) # else 분기 커버

TDD 모범 사례

import pytest
from dataclasses import dataclass, field
from typing import Optional


# ===== 설계 우선, 구현 나중 =====
# 테스트를 통해 API를 설계함

@dataclass
class Product:
id: int
name: str
price: float
stock: int = 0


class Inventory:
def __init__(self):
self._products: dict[int, Product] = {}

def add_product(self, product: Product) -> None:
self._products[product.id] = product

def get_product(self, product_id: int) -> Optional[Product]:
return self._products.get(product_id)

def update_stock(self, product_id: int, quantity: int) -> None:
product = self.get_product(product_id)
if product is None:
raise KeyError(f"상품 없음: {product_id}")
new_stock = product.stock + quantity
if new_stock < 0:
raise ValueError("재고는 0 이상이어야 합니다")
product.stock = new_stock

def available_products(self) -> list[Product]:
return [p for p in self._products.values() if p.stock > 0]


# 테스트: 인벤토리 동작 검증
@pytest.fixture
def inventory():
inv = Inventory()
inv.add_product(Product(id=1, name="Python 책", price=35000, stock=10))
inv.add_product(Product(id=2, name="마우스", price=50000, stock=0))
return inv


def test_get_existing_product(inventory):
product = inventory.get_product(1)
assert product is not None
assert product.name == "Python 책"


def test_get_missing_product(inventory):
assert inventory.get_product(999) is None


def test_update_stock_add(inventory):
inventory.update_stock(1, 5)
assert inventory.get_product(1).stock == 15


def test_update_stock_remove(inventory):
inventory.update_stock(1, -10)
assert inventory.get_product(1).stock == 0


def test_update_stock_below_zero(inventory):
with pytest.raises(ValueError, match="재고는 0 이상"):
inventory.update_stock(1, -100)


def test_available_products(inventory):
available = inventory.available_products()
assert len(available) == 1
assert available[0].name == "Python 책"

정리

단계설명
🔴 Red실패하는 테스트 작성 (구현 전)
🟢 Green최소한의 코드로 테스트 통과
🔵 Refactor품질 개선 (테스트는 계속 통과)
--cov코드 커버리지 측정
--cov-fail-under=N최소 커버리지 강제
--cov-report=htmlHTML 리포트 생성

TDD의 핵심은 테스트가 설계 도구라는 점입니다. 실패하는 테스트를 먼저 작성함으로써 인터페이스를 명확히 정의하고, 필요한 것만 구현하게 됩니다.

Advertisement