ESC
Type to search guides, tutorials, and reference documentation.
Verified by Garnet Grid

Test-Driven Development: Writing Tests That Drive Better Design

Practice TDD as a design tool, not just a testing technique. Covers the red-green-refactor cycle, test doubles, testing strategies for different layers, when TDD helps and when it hurts, and the workflow that produces well-designed code with comprehensive test coverage as a side effect.

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 ─────────────────────┘

Example: Building a Shopping Cart

# 🔴 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

DoublePurposeExample
StubReturn predetermined valuespayment_gateway.charge() → returns success
MockVerify interactionsassert email_service.send was called with user.email
FakeSimplified working implementationIn-memory database instead of PostgreSQL
SpyRecord calls for later assertionlogger.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

LayerWhat to TestTest TypeSpeed
Domain logicBusiness rules, calculations, state transitionsUnit< 1ms
Application servicesOrchestration, use casesUnit (with mocks)< 10ms
API endpointsRequest/response format, status codes, validationIntegration< 100ms
Database queriesComplex queries, migrationsIntegration (test DB)< 500ms
Full user flowsEnd-to-end critical pathsE2E< 30s

When TDD Helps and When It Does Not

TDD HelpsTDD Hurts
Business logic with clear rulesExploratory prototyping
API contract designUI layout and styling
Complex state machinesSimple CRUD with no logic
Refactoring existing codeCode you will throw away
Libraries and frameworksIntegration with unknown APIs
Long-lived production codeSpike/proof-of-concept

Common TDD Mistakes

MistakeProblemFix
Testing implementationTests break when refactoringTest behavior and outcomes, not internals
Too many mocksTests pass but code is brokenUse real dependencies where practical
One giant testHard to diagnose failuresOne assertion per test (or closely related assertions)
Skipping refactorCode passes tests but is messyRefactor step is not optional
Testing private methodsCouples tests to implementationTest through the public interface

Implementation Checklist

  • Start with the simplest failing test before writing any production code
  • Write the minimum code to make the test pass — resist gold-plating
  • Refactor after green — extract, rename, simplify while tests stay green
  • Test behavior and outcomes, not implementation details
  • Use stubs to control dependencies, mocks to verify interactions
  • Keep unit tests fast: < 10ms each, full suite < 30 seconds
  • Write integration tests for database queries and API endpoints
  • Do not mock what you do not own — wrap external APIs in adapters
  • Run tests on every commit via CI, block merges on failure
  • Treat test code as production code: named well, no duplication, maintained
Jakub Dimitri Rezayev
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 →