Test-Driven Development (TDD) Cheat Sheet
Covers the red-green-refactor loop, writing the smallest failing test first, test doubles, and common TDD anti-patterns to avoid.
The Red-Green-Refactor Loop
Write a failing test, make it pass with the simplest code, then clean up — repeat in small steps.
# 1. RED — write a failing test for behavior that doesn't exist yetdef test_calculates_total_with_tax(): cart = ShoppingCart() cart.add_item(price=100, tax_rate=0.08) assert cart.total() == 108# 2. GREEN — write the minimum code to passclass ShoppingCart: def __init__(self): self.items = [] def add_item(self, price, tax_rate): self.items.append((price, tax_rate)) def total(self): return sum(p * (1 + t) for p, t in self.items)# 3. REFACTOR — clean up implementation/tests with the safety net of a green test# (extract a Money value object, rename variables, etc.) — behavior stays the same
Test Doubles: Stub, Mock, Fake, Spy
Isolate the unit under test from slow/external dependencies.
from unittest.mock import Mockdef test_sends_confirmation_email_on_order(): email_service = Mock() # mock: verifies interaction order_service = OrderService(email_service=email_service) order_service.place_order(order_id=1, email="[email protected]") email_service.send.assert_called_once_with( to="[email protected]", template="order_confirmation" )class FakePaymentGateway: # fake: working lightweight implementation def __init__(self): self.charges = [] def charge(self, amount): self.charges.append(amount) return Truedef test_charges_payment_gateway(): gateway = FakePaymentGateway() service = CheckoutService(gateway) service.checkout(amount=50) assert gateway.charges == [50]
Parametrized Tests for Edge Cases
TDD works best when you drive out edge cases one small test at a time.
import pytest@pytest.mark.parametrize("price,tax_rate,expected", [ (100, 0.0, 100), # no tax (100, 0.08, 108), # standard case (0, 0.08, 0), # zero price (-10, 0.08, ValueError), # invalid: negative price should raise])def test_total_with_various_inputs(price, tax_rate, expected): cart = ShoppingCart() if expected is ValueError: with pytest.raises(ValueError): cart.add_item(price, tax_rate) else: cart.add_item(price, tax_rate) assert cart.total() == expected
TDD Principles & Anti-Patterns
What good TDD looks like, and the traps that erode it.
- Arrange-Act-Assert- structure every test in these three clear sections
- One assertion concept per test- keeps failures diagnostic and tests independent
- Test behavior, not implementation- anti-pattern: asserting on private internals couples tests to refactors
- Slow test suite- anti-pattern: hitting real DB/network in unit tests kills the fast feedback loop
- Fragile mocks- anti-pattern: over-mocking collaborators makes tests break on harmless refactors
- Triangulation- generalize an implementation only once a second test forces you to
- F.I.R.S.T.- Fast, Independent, Repeatable, Self-validating, Timely — the properties of a good test
Outside-In (London School / Mockist) TDD
Start from an acceptance test at the system boundary, then mock-drive each collaborator's interface into existence before implementing it.
# Acceptance test drives the top-level behavior firstdef test_checkout_charges_card_and_sends_receipt(): gateway = Mock(spec=PaymentGateway) mailer = Mock(spec=Mailer) checkout = CheckoutService(gateway, mailer) checkout.complete(order=Order(total=42.0), card="tok_123") gateway.charge.assert_called_once_with("tok_123", 42.0) mailer.send_receipt.assert_called_once()# The mock's expected interface (gateway.charge, mailer.send_receipt)# becomes the *design contract* for collaborators that don't exist yet.# Each collaborator then gets its own unit-level TDD cycle,# working inward from the outside boundary toward concrete classes.
Classical (Detroit School / Classicist) TDD
Prefer real collaborators and state-based assertions over mocks; only fake true external boundaries (network, clock, filesystem).
# State-based assertion using real collaborators, no mocking of internalsdef test_checkout_applies_loyalty_discount(): catalog = InMemoryCatalog(items=[Item("widget", price=100)]) loyalty = LoyaltyProgram(tier="gold") # real object, cheap to construct cart = ShoppingCart(catalog, loyalty) cart.add("widget", qty=2) # assert on resulting state, not on which methods were called assert cart.total() == 180 # gold tier = 10% off# Only the genuine external edge (e.g. a payment gateway) gets a test double;# everything else is a real, fast, in-memory collaborator.
Characterization Tests for Legacy Code
Before refactoring code with no tests, pin down its CURRENT behavior (bugs included) with tests, then refactor safely underneath them.
# Step 1: write a test that documents actual (not desired) behaviordef test_legacy_pricing_current_behavior(): # Golden-master style: capture today's output as the baseline. # This is NOT asserting correctness — it's asserting "don't change # this without knowing it." result = legacy_calculate_price(qty=3, unit_price=9.99, region="EU") assert result == 32.4693 # rounding quirk preserved intentionally# Step 2: once characterization tests are green and committed,# refactor the implementation freely — the tests catch regressions.# Step 3: only THEN write new TDD-style tests for corrected behavior,# and update the characterization test to match the fix deliberately.
TDD in Legacy & Large Codebases
Techniques for applying TDD where you can't simply start from a blank slate.
- Seam- a place you can alter behavior without editing the code in that place (e.g. dependency injection point) — the entry point for testing untested code
- Sprout method/class- write new logic as a small, fully-tested new method/class called from untested legacy code, rather than editing the legacy code directly
- Golden master testing- capture a large snapshot of current output and diff against it, used when unit-level seams don't exist yet
- Test-induced damage- when over-mocking or excessive DI purely for testability harms the production design; a signal to reconsider granularity
- Mutation testing- deliberately injects small code changes (mutants) and checks tests fail, measuring whether tests actually assert anything meaningful
- Contract tests- verify a test double's behavior matches the real dependency it stands in for, preventing mocks from drifting out of sync
If you're stuck writing the 'right' implementation, write the most deliberately fake/hardcoded version that passes the test first (e.g. `return 108`) — it forces you to write the next failing test that breaks the fake, which naturally drives out the real logic.