TDD is not about testing. It is about design. The tests come first, but the real benefit is that writing tests first forces you to think about the interface before the implementation. What does this function take? What does it return? What are the edge cases? These questions, answered before you write a single line of production code, produce better APIs and fewer bugs.
The teams that succeed with TDD treat it as a design technique that happens to produce tests. The teams that fail treat it as a testing obligation that slows them down.
The Red-Green-Refactor Cycle
┌─────────────────────────────────────────────┐
│ │
│ 🔴 RED: Write a failing test │
│ │ - Test describes desired behavior │
│ │ - Test must fail (proves it tests │
│ │ something real) │
│ │ │
│ ▼ │
│ 🟢 GREEN: Write minimum code to pass │
│ │ - Simplest possible implementation │
│ │ - Do NOT optimize or clean up yet │
│ │ - Just make the test pass │
│ │ │
│ ▼ │
│ 🔵 REFACTOR: Clean up while tests pass │
│ │ - Remove duplication │
│ │ - Improve naming │
│ │ - Extract methods/classes │
│ │ - Tests stay green throughout │
│ │ │
│ └──────────── Repeat ─────────────────────┘
# 🔴 RED: Write the first test
def test_empty_cart_has_zero_total():
cart = ShoppingCart()
assert cart.total == 0.0
# 🟢 GREEN: Minimum code to pass
class ShoppingCart:
@property
def total(self):
return 0.0
# 🔴 RED: Next behavior
def test_cart_with_one_item():
cart = ShoppingCart()
cart.add_item("Widget", price=9.99, quantity=1)
assert cart.total == 9.99
# 🟢 GREEN: Make it pass
class ShoppingCart:
def __init__(self):
self._items = []
def add_item(self, name, price, quantity):
self._items.append({"name": name, "price": price, "quantity": quantity})
@property
def total(self):
return sum(item["price"] * item["quantity"] for item in self._items)
# 🔴 RED: Edge case
def test_cart_with_discount():
cart = ShoppingCart()
cart.add_item("Widget", price=100.0, quantity=1)
cart.apply_discount(percentage=10)
assert cart.total == 90.0
# 🟢 GREEN → 🔵 REFACTOR: Extract Item class, clean up
Test Doubles
| Double | Purpose | Example |
|---|
| Stub | Return predetermined values | payment_gateway.charge() → returns success |
| Mock | Verify interactions | assert email_service.send was called with user.email |
| Fake | Simplified working implementation | In-memory database instead of PostgreSQL |
| Spy | Record calls for later assertion | logger.spy() → assert logger.called_with("error", ...) |
# Stub: control what the dependency returns
def test_checkout_with_successful_payment():
payment_gateway = Mock()
payment_gateway.charge.return_value = PaymentResult(success=True, id="pay_123")
checkout = CheckoutService(payment_gateway=payment_gateway)
result = checkout.process(cart, payment_method)
assert result.status == "completed"
assert result.payment_id == "pay_123"
# Mock: verify the interaction happened correctly
def test_checkout_sends_confirmation_email():
email_service = Mock()
checkout = CheckoutService(email_service=email_service)
checkout.process(cart, payment_method)
email_service.send.assert_called_once_with(
to=user.email,
template="order_confirmation",
data={"order_id": ANY}
)
What to Test at Each Layer
| Layer | What to Test | Test Type | Speed |
|---|
| Domain logic | Business rules, calculations, state transitions | Unit | < 1ms |
| Application services | Orchestration, use cases | Unit (with mocks) | < 10ms |
| API endpoints | Request/response format, status codes, validation | Integration | < 100ms |
| Database queries | Complex queries, migrations | Integration (test DB) | < 500ms |
| Full user flows | End-to-end critical paths | E2E | < 30s |
When TDD Helps and When It Does Not
| TDD Helps | TDD Hurts |
|---|
| Business logic with clear rules | Exploratory prototyping |
| API contract design | UI layout and styling |
| Complex state machines | Simple CRUD with no logic |
| Refactoring existing code | Code you will throw away |
| Libraries and frameworks | Integration with unknown APIs |
| Long-lived production code | Spike/proof-of-concept |
Common TDD Mistakes
| Mistake | Problem | Fix |
|---|
| Testing implementation | Tests break when refactoring | Test behavior and outcomes, not internals |
| Too many mocks | Tests pass but code is broken | Use real dependencies where practical |
| One giant test | Hard to diagnose failures | One assertion per test (or closely related assertions) |
| Skipping refactor | Code passes tests but is messy | Refactor step is not optional |
| Testing private methods | Couples tests to implementation | Test through the public interface |
Implementation Checklist
Jakub Dimitri Rezayev
Founder & Chief Architect • Garnet Grid Consulting
Jakub holds an M.S. in Customer Intelligence & Analytics and a B.S. in Finance & Computer Science from Pace University. With deep expertise spanning D365 F&O, Azure, Power BI, and AI/ML systems, he architects enterprise solutions that bridge legacy systems and modern technology — and has led multi-million dollar ERP implementations for Fortune 500 supply chains.
View Full Profile →