Skip to main content
Advertisement

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

StepDescription
🔴 RedWrite a failing test (before implementation)
🟢 GreenWrite minimum code to pass the test
🔵 RefactorImprove quality (tests keep passing)
--covMeasure code coverage
--cov-fail-under=NEnforce minimum coverage
--cov-report=htmlGenerate 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.

Advertisement