TDD & BDD#

Test-Driven Development (TDD) and Behaviour-Driven Development (BDD) are development workflows β€” not testing techniques. They change when and how you write tests, fundamentally altering your approach to design and implementation.

Test-Driven Development (TDD)#

The Red-Green-Refactor Cycle#

graph LR
    RED["πŸ”΄ RED<br/>Write a failing test"] --> GREEN["🟒 GREEN<br/>Write minimal code to pass"]
    GREEN --> REFACTOR["πŸ”΅ REFACTOR<br/>Improve design, keep green"]
    REFACTOR --> RED
PhaseWhat You DoRules
RedWrite a test that describes desired behaviourTest must fail. If it passes, you haven’t written the right test.
GreenWrite the simplest code that makes the test passNo more code than necessary. “Fake it ’til you make it.”
RefactorImprove code structure without changing behaviourAll tests stay green. Remove duplication.

TDD Example: Building a Password Validator#

Step 1 β€” Red: Write the first test

def test_password_must_be_at_least_8_characters():
    assert validate_password("short") == {
        "valid": False,
        "errors": ["Must be at least 8 characters"],
    }

Running this fails β€” validate_password doesn’t exist yet.

Step 2 β€” Green: Minimal implementation

def validate_password(password: str) -> dict:
    errors = []
    if len(password) < 8:
        errors.append("Must be at least 8 characters")
    return {"valid": len(errors) == 0, "errors": errors}

Test passes. βœ…

Step 3 β€” Red again: Add next requirement

def test_password_must_contain_uppercase():
    result = validate_password("alllowercase")
    assert "Must contain at least one uppercase letter" in result["errors"]

def test_valid_password():
    result = validate_password("SecurePass1!")
    assert result["valid"] is True
    assert result["errors"] == []

Step 4 β€” Green: Extend implementation

def validate_password(password: str) -> dict:
    errors = []
    if len(password) < 8:
        errors.append("Must be at least 8 characters")
    if not any(c.isupper() for c in password):
        errors.append("Must contain at least one uppercase letter")
    return {"valid": len(errors) == 0, "errors": errors}

Step 5 β€” Refactor: Extract rule pattern

PASSWORD_RULES = [
    (lambda p: len(p) >= 8, "Must be at least 8 characters"),
    (lambda p: any(c.isupper() for c in p), "Must contain at least one uppercase letter"),
    (lambda p: any(c.isdigit() for c in p), "Must contain at least one digit"),
]

def validate_password(password: str) -> dict:
    errors = [msg for check, msg in PASSWORD_RULES if not check(password)]
    return {"valid": len(errors) == 0, "errors": errors}

All tests still pass. Design improved. βœ…

When TDD Helps Most#

ScenarioWhy TDD Works Well
Pure logic (parsers, validators, calculations)Clear inputs and outputs, easy to define tests first
Well-understood requirementsYou know what the code should do before writing it
Refactoring legacy codeWrite tests around existing behaviour, then refactor safely
Algorithm designTests define expected output, you iterate toward solution
API designWrite the consumer first (test), then build the implementation

When TDD Is Less Effective#

ScenarioWhy
Exploratory prototypingRequirements are unknown β€” you’re discovering what to build
UI layout/stylingVisual correctness can’t be easily expressed in assertions
Integration with unfamiliar APIsNeed to experiment before knowing what to test
One-off scriptsCost of TDD exceeds lifetime value of the tests

Outside-In vs Inside-Out TDD#

graph TD
    subgraph "Outside-In (London School)"
        O1[Start with acceptance test] --> O2[Write failing integration test]
        O2 --> O3[Mock collaborators]
        O3 --> O4[Implement each collaborator]
    end
    subgraph "Inside-Out (Detroit School)"
        I1[Start with smallest unit] --> I2[Build up from foundations]
        I2 --> I3[Compose units together]
        I3 --> I4[Integration emerges naturally]
    end
ApproachStarting PointMockingDesign Driver
Outside-InUser-facing behaviourHeavy (mock collaborators)Interfaces designed top-down
Inside-OutCore domain logicMinimal (real objects)Design emerges bottom-up

Outside-In Example#

# Start with what the user sees
def test_checkout_creates_order_and_charges_payment():
    # This test drives the design of CheckoutService
    checkout = CheckoutService(
        payment_gateway=mock_gateway,
        order_repository=mock_repo,
    )
    result = checkout.execute(cart=cart, payment_method=card)
    assert result.success is True

# Then implement CheckoutService, which defines what it needs from collaborators

Inside-Out Example#

# Start with core domain
def test_order_calculates_total():
    order = Order(items=[Item("Widget", 10), Item("Gadget", 20)])
    assert order.total == 30

# Then build up to the service layer
def test_checkout_service_uses_real_order():
    order = Order(items=[Item("Widget", 10)])
    service = CheckoutService(repo=InMemoryRepo(), gateway=FakeGateway())
    result = service.execute(order)
    assert result.success is True

Behaviour-Driven Development (BDD)#

BDD extends TDD by writing tests in natural language that stakeholders can read:

Gherkin Syntax#

Feature: Shopping Cart
  As a customer
  I want to manage items in my cart
  So that I can purchase what I need

  Scenario: Add item to empty cart
    Given I have an empty cart
    When I add a "Widget" priced at $9.99
    Then my cart should contain 1 item
    And the cart total should be $9.99

  Scenario: Apply discount code
    Given I have a cart with items totalling $50.00
    When I apply discount code "SAVE10"
    Then the cart total should be $45.00

  Scenario: Invalid discount code
    Given I have a cart with items totalling $50.00
    When I apply discount code "FAKE"
    Then I should see an error "Invalid discount code"
    And the cart total should remain $50.00

Step Definitions (Python β€” behave)#

from behave import given, when, then

@given('I have an empty cart')
def step_empty_cart(context):
    context.cart = Cart()

@when('I add a "{item}" priced at ${price:f}')
def step_add_item(context, item, price):
    context.cart.add(Item(name=item, price=price))

@then('my cart should contain {count:d} item(s)')
def step_cart_count(context, count):
    assert len(context.cart.items) == count

@then('the cart total should be ${total:f}')
def step_cart_total(context, total):
    assert context.cart.total == total

Step Definitions (JavaScript β€” Cucumber)#

import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';

Given('I have an empty cart', function () {
  this.cart = new Cart();
});

When('I add a {string} priced at ${float}', function (item, price) {
  this.cart.add({ name: item, price });
});

Then('my cart should contain {int} item(s)', function (count) {
  expect(this.cart.items).to.have.lengthOf(count);
});

Then('the cart total should be ${float}', function (total) {
  expect(this.cart.total).to.equal(total);
});

Living Documentation#

BDD scenarios serve as executable specifications:

graph LR
    A[Business Requirements] --> B[Gherkin Scenarios]
    B --> C[Step Definitions<br/>Automated Tests]
    B --> D[Living Documentation<br/>Always Current]
    C --> E[CI Pipeline<br/>Validates Requirements]
BenefitHow
Shared understandingProduct, dev, and QA agree on behaviour before coding
Executable specsScenarios run as automated tests
Living documentationIf tests pass, the documentation is accurate
Regression detectionChanged behaviour immediately breaks the scenario

BDD vs TDD#

AspectTDDBDD
AudienceDevelopersDevelopers + Product + QA
LanguageCode (test framework)Natural language (Gherkin)
ScopeUnit/function levelFeature/behaviour level
GoalDrive code designDrive feature understanding
When to useAlways (for code design)When stakeholder alignment matters

Kata Exercises#

Practice TDD with these classic exercises (increasing difficulty):

KataConcepts Practiced
FizzBuzzBasic TDD cycle, simple conditionals
String CalculatorIncremental design, parsing
Roman NumeralsPattern recognition, refactoring
Bowling GameState management, scoring rules
Mars RoverObject design, command pattern
Bank AccountTime-dependent logic, formatting
Game of LifeGrid operations, neighbour logic

FizzBuzz Kata β€” TDD Walkthrough#

# Test 1: Regular number
def test_returns_number_as_string():
    assert fizzbuzz(1) == "1"

# Test 2: Divisible by 3
def test_returns_fizz_for_multiples_of_3():
    assert fizzbuzz(3) == "Fizz"
    assert fizzbuzz(6) == "Fizz"

# Test 3: Divisible by 5
def test_returns_buzz_for_multiples_of_5():
    assert fizzbuzz(5) == "Buzz"
    assert fizzbuzz(10) == "Buzz"

# Test 4: Divisible by both
def test_returns_fizzbuzz_for_multiples_of_15():
    assert fizzbuzz(15) == "FizzBuzz"
    assert fizzbuzz(30) == "FizzBuzz"

Write each test, watch it fail, implement the minimum, refactor if needed, repeat.

Key Takeaways#

  • TDD is a design practice, not a testing practice β€” it produces tests as a by-product of good design
  • The Red-Green-Refactor cycle forces you to think about requirements before implementation
  • Outside-In TDD designs interfaces from the consumer’s perspective; Inside-Out builds from domain logic up
  • BDD bridges the gap between technical tests and business requirements using natural language
  • Gherkin scenarios serve as living documentation that stays accurate as long as tests pass
  • TDD works best for well-understood requirements and pure logic; skip it for exploratory prototyping
  • Practice with katas β€” the discipline of TDD improves with repetition