TDD — Test-Driven Development
TDD (Test-Driven Development) is a development methodology where you write tests before writing code. It follows the Red → Green → Refactor cycle.
The Red-Green-Refactor Cycle
🔴 Red → Write a failing test
🟢 Green → Write the minimum code to pass the test
🔵 Refactor → Remove duplication, improve code (tests keep passing)
# Example: Bank account — step-by-step TDD
# ===== Step 1: Red =====
# BankAccount class doesn't exist yet — fails
def test_initial_balance():
account = BankAccount() # NameError
assert account.balance == 0
# ===== Step 2: Green =====
# Minimum code to pass the test
class BankAccount:
def __init__(self):
self.balance = 0
def test_initial_balance():
account = BankAccount()
assert account.balance == 0 # ✅ passes
# ===== Step 3: Next Red =====
def test_deposit():
account = BankAccount()
account.deposit(1000)
assert account.balance == 1000 # fails (no deposit method)
# ===== Step 4: Green =====
class BankAccount:
def __init__(self):
self.balance = 0
def deposit(self, amount: int) -> None:
self.balance += amount
# ===== Step 5: Next Red =====
def test_withdraw():
account = BankAccount()
account.deposit(5000)
account.withdraw(2000)
assert account.balance == 3000 # fails
def test_withdraw_insufficient():
account = BankAccount()
account.deposit(1000)
with pytest.raises(ValueError, match="Insufficient funds"):
account.withdraw(2000) # fails
# ===== Step 6: Green + Refactor =====
import pytest
class BankAccount:
def __init__(self):
self.balance = 0
def deposit(self, amount: int) -> None:
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.balance += amount
def withdraw(self, amount: int) -> None:
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
Real-World TDD: Shopping Cart
import pytest
# ===== Step 1: Empty cart =====
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: Add items =====
def test_add_item():
cart = ShoppingCart()
cart.add_item("Python Book", 35000, qty=1)
assert cart.total == 35000
assert cart.item_count == 1
def test_add_multiple_items():
cart = ShoppingCart()
cart.add_item("Python Book", 35000)
cart.add_item("Mouse", 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: Apply discount =====
def test_apply_discount():
cart = ShoppingCart()
cart.add_item("Python Book", 35000)
cart.apply_discount(10) # 10% off
assert cart.total == pytest.approx(31500)
def test_invalid_discount():
cart = ShoppingCart()
with pytest.raises(ValueError, match="discount"):
cart.apply_discount(110) # over 100% not allowed
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"Discount must be between 0 and 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 — Code Coverage
# pip install pytest-cov
# Measure coverage
pytest --cov=src tests/
pytest --cov=src --cov-report=term-missing tests/
pytest --cov=src --cov-report=html tests/ # generates htmlcov/ directory
# Enforce minimum coverage (useful in CI)
pytest --cov=src --cov-fail-under=80 tests/
# Coverage analysis example
# 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"Unknown operation: {op}")
# test_calculator.py — achieving 100% coverage
import pytest
def test_add():
assert calculate("add", 1, 2) == 3 # covers "add" branch
def test_sub():
assert calculate("sub", 5, 3) == 2 # covers "sub" branch
def test_mul():
assert calculate("mul", 4, 3) == 12 # covers "mul" branch
def test_div():
assert calculate("div", 10, 2) == 5.0 # covers "div" branch
def test_div_zero():
with pytest.raises(ZeroDivisionError):
calculate("div", 1, 0) # covers b==0 branch
def test_unknown_op():
with pytest.raises(ValueError):
calculate("mod", 5, 3) # covers else branch
TDD Best Practices
import pytest
from dataclasses import dataclass, field
from typing import Optional
# ===== Design first, implement later =====
# Tests define the API before implementation
@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 not found: {product_id}")
new_stock = product.stock + quantity
if new_stock < 0:
raise ValueError("Stock cannot go below zero")
product.stock = new_stock
def available_products(self) -> list[Product]:
return [p for p in self._products.values() if p.stock > 0]
# Tests: verify inventory behavior
@pytest.fixture
def inventory():
inv = Inventory()
inv.add_product(Product(id=1, name="Python Book", price=35000, stock=10))
inv.add_product(Product(id=2, name="Mouse", 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 Book"
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="Stock cannot go below zero"):
inventory.update_stock(1, -100)
def test_available_products(inventory):
available = inventory.available_products()
assert len(available) == 1
assert available[0].name == "Python Book"
Summary
| Step | Description |
|---|---|
| 🔴 Red | Write a failing test (before implementation) |
| 🟢 Green | Write minimum code to pass the test |
| 🔵 Refactor | Improve quality (tests keep passing) |
--cov | Measure code coverage |
--cov-fail-under=N | Enforce minimum coverage |
--cov-report=html | Generate HTML report |
The core insight of TDD is that tests are a design tool. Writing a failing test first forces you to define the interface clearly and implement only what is needed.