TDD & BDD

TDD is not about testing. It's about design." — Kent Beck [^beck2002]

The Core Cycles

TDD — Red → Green → Refactor

graph LR RED[RED: Write failing test] --> GREEN[GREEN: Make it pass with minimal code] GREEN --> REFACTOR[REFACTOR: Clean up without changing behaviour] REFACTOR --> RED

BDD — Given → When → Then

graph LR GIVEN[GIVEN: Context] --> WHEN[WHEN: Action] WHEN --> THEN[THEN: Outcome]

TDD vs BDD — Complementary, Not Competing

Dimension TDD BDD Relationship
Focus Developer-facing design Business-facing behaviour BDD = TDD at higher level
Language Code (unit test syntax) Natural language (Gherkin) BDD specs → TDD tests
Audience Developers Devs + QA + Product + Stakeholders Shared vocabulary
Granularity Function/method User journey / feature BDD decomposes to TDD
Tooling xUnit frameworks Cucumber, SpecFlow, Behave BDD tools drive TDD

Key insight: BDD scenarios become the acceptance criteria that drive TDD cycles. You don't choose one — you do both at different levels.


TDD in Practice — The Three Laws

  1. You may not write production code until you have written a failing unit test.
  2. You may not write more of a unit test than is sufficient to fail (and not compile is failing).
  3. You may not write more production code than is sufficient to pass the currently failing test.

Example: Implementing a Shopping Cart

# 1. RED — Write failing test
def test_empty_cart_has_zero_total():
    cart = Cart()
    assert cart.total() == Money.zero()

# 2. GREEN — Minimal implementation
class Cart:
    def total(self):
        return Money.zero()

# 3. REFACTOR — Clean up (none needed yet)
# Repeat for: add_item, remove_item, quantity, discounts...

# Real example after several cycles:
class Cart:
    def __init__(self):
        self._items = []

    def add(self, product: Product, qty: int = 1):
        self._items.append(LineItem(product, qty))

    def total(self) -> Money:
        return sum(item.subtotal() for item in self._items)
#include <memory>
#include <vector>

class Money {
public:
    explicit Money(long cents = 0) : cents_(cents) {}
    static Money zero() { return Money(0); }
    // operators...
private:
    long cents_;
};

class Product { /* ... */ };
class LineItem { /* ... */ };

// 1. RED — Write failing test
void test_empty_cart_has_zero_total() {
    Cart cart;
    assert(cart.total() == Money::zero());
}

// 2. GREEN — Minimal implementation
class Cart {
public:
    Money total() const { return Money::zero(); }
};

// 3. REFACTOR — Clean up (none needed yet)
// Repeat for: add_item, remove_item, quantity, discounts...

// Real example after several cycles:
class Cart {
    std::vector<LineItem> items_;
public:
    void add(const Product& product, int qty = 1) {
        items_.emplace_back(product, qty);
    }

    Money total() const {
        Money total = Money::zero();
        for (const auto& item : items_)
            total += item.subtotal();
        return total;
    }
private:
    std::vector<LineItem> items_;
};
import java.util.*;

public final class Money {
    private final long cents;
    public Money(long cents) { this.cents = cents; }
    public static Money zero() { return new Money(0); }
    // operators...
}

public final class Product { /* ... */ }
public final class LineItem { /* ... */ }

// 1. RED — Write failing test
@Test
void testEmptyCartHasZeroTotal() {
    Cart cart = new Cart();
    assertEquals(Money.zero(), cart.total());
}

// 2. GREEN — Minimal implementation
public final class Cart {
    public Money total() { return Money.zero(); }
}

// 3. REFACTOR — Clean up (none needed yet)
// Repeat for: add_item, remove_item, quantity, discounts...

// Real example after several cycles:
public final class Cart {
    private final List<LineItem> items = new ArrayList<>();

    public void add(Product product, int qty) {
        items.add(new LineItem(product, qty));
    }

    public Money total() {
        Money total = Money.zero();
        for (LineItem item : items) {
            total = total.add(item.subtotal());
        }
        return total;
    }
}
using System;
using System.Collections.Generic;

public readonly record struct Money(long Cents) {
    public static Money Zero => new Money(0);
    public static Money operator +(Money a, Money b) => new Money(a.Cents + b.Cents);
}

// 1. RED — Write failing test
[Fact]
public void TestEmptyCartHasZeroTotal() {
    var cart = new Cart();
    Assert.Equal(Money.Zero, cart.Total());
}

// 2. GREEN — Minimal implementation
public sealed class Cart {
    public Money Total() => Money.Zero;
}

// 3. REFACTOR — Clean up (none needed yet)
// Repeat for: add_item, remove_item, quantity, discounts...

// Real example after several cycles:
public sealed class Cart {
    private readonly List<LineItem> _items = new();

    public void Add(Product product, int qty = 1) =>
        _items.Add(new LineItem(product, qty));

    public Money Total() => _items.Aggregate(Money.Zero, (sum, item) => sum + item.Subtotal());
}
# 1. RED — Write failing test (RSpec)
RSpec.describe Cart do
  it "has zero total when empty" do
    expect(Cart.new.total).to eq(Money.zero)
  end
end

# 2. GREEN — Minimal implementation
class Cart
  def total
    Money.zero
  end
end

# 3. REFACTOR — Clean up (none needed yet)
# Repeat for: add_item, remove_item, quantity, discounts...

# Real example after several cycles:
class Cart
  def initialize
    @items = []
  end

  def add(product, qty = 1)
    @items << LineItem.new(product, qty)
  end

  def total
    @items.sum(Money.zero, &:subtotal)
  end
end

BDD — Specification by Example

The Three Amigos

Role Contribution
Product Business rules, acceptance criteria
Development Technical feasibility, edge cases
QA/Testing Edge cases, negative scenarios, automation

Living Documentation

gherkin (executable spec)
    
    
Cucumber/SpecFlow runs it
    
    
Each step  Glue code  TDD implementation
    
    
HTML report = Living Documentation
    
    
Always in sync with code!

Gherkin Best Practices

Anti-pattern Better
Given I am on the login page Given I have an account
When I click the blue button When I submit my credentials
Then I should see success message Then I am logged in
UI-coupled steps Business-intent steps
Imperative (how) Declarative (what)

TDD/BDD Tooling Ecosystem

Language TDD Framework BDD Framework Notes
Python pytest, unittest behave, pytest-bdd pytest-bdd = pytest + Gherkin
JavaScript/TypeScript Vitest, Jest Cucumber.js, Playwright BDD Playwright has built-in BDD
Java JUnit 5, TestNG Cucumber JVM, JGiven JGiven = code-based BDD
C#/.NET xUnit, NUnit SpecFlow, BDDfy SpecFlow = mature
Go testing, testify Godog, Ginkgo Godog = Cucumber for Go
Rust built-in test cucumber-rust Growing ecosystem

Example: From BDD Scenario to TDD Implementation

1. Feature File (Business)

A Gherkin feature file is deliberately language-agnostic — that's the whole point of the format, so the same scenario reads identically to a Python team, a Java team, or a business stakeholder who's never seen either codebase:

Feature: Discount Calculation

  Scenario: Gold tier customer gets loyalty discount
    Given a customer with "gold" tier and 3 years active
    And a cart with total $1000
    When the discount is calculated
    Then the discount should be $150

2. Step Definitions (Glue)

# tests/steps/discount_steps.py
from pytest_bdd import given, when, then, parsers

@given(parsers.parse('a customer with "{tier}" tier and {years:d} years active'))
def customer(tier, years):
    return Customer(tier=tier, years_active=years)

@given(parsers.parse('a cart with total ${amount:d}'))
def cart(amount):
    return Cart(Money(amount))

@when('the discount is calculated')
def calculate_discount(customer, cart):
    return PricingEngine().calculate_discount(customer, cart)

@then(parsers.parse('the discount should be ${amount:d}'))
def verify_discount(discount, amount):
    assert discount == Money(amount)
// tests/steps/discount_steps.cpp
#include <string>
#include <cassert>

class Customer { /* ... */ };
class Cart { /* ... */ };
class Money { /* ... */ };
class PricingEngine { /* ... */ };

void customer_step(const std::string& tier, int years) {
    return Customer(tier, years);
}

void cart_step(int amount) {
    return Cart(Money(amount));
}

Money calculate_discount_step(const Customer& customer, const Cart& cart) {
    return PricingEngine().calculate_discount(customer, cart);
}

void verify_discount_step(const Money& discount, int amount) {
    assert(discount == Money(amount));
}
// tests/steps/discount_steps.java
import io.cucumber.java.en.*;

public class DiscountSteps {
    private Customer customer;
    private Cart cart;
    private Money discount;

    @Given("a customer with {string} tier and {int} years active")
    public void customer(String tier, int years) {
        customer = new Customer(tier, years);
    }

    @Given("a cart with total ${int}")
    public void cart(int amount) {
        cart = new Cart(new Money(amount));
    }

    @When("the discount is calculated")
    public void calculate_discount() {
        discount = new PricingEngine().calculate_discount(customer, cart);
    }

    @Then("the discount should be ${int}")
    public void verify_discount(int amount) {
        assert discount.equals(new Money(amount));
    }
}
// tests/steps/discount_steps.cs
using TechTalk.SpecFlow;

[Binding]
public class DiscountSteps {
    private Customer _customer;
    private Cart _cart;
    private Money _discount;

    [Given(@"a customer with ""(.*)"" tier and (\d+) years active")]
    public void Customer(string tier, int years) {
        _customer = new Customer(tier, years);
    }

    [Given(@"a cart with total \$(.*)")]
    public void Cart(int amount) {
        _cart = new Cart(new Money(amount));
    }

    [When(@"the discount is calculated")]
    public void CalculateDiscount() {
        _discount = new PricingEngine().CalculateDiscount(_customer, _cart);
    }

    [Then(@"the discount should be \$(.*)")]
    public void VerifyDiscount(int amount) {
        Assert.AreEqual(new Money(amount), _discount);
    }
}
# features/step_definitions/discount_steps.rb (Cucumber)
Given(/^a customer with "([^"]*)" tier and (\d+) years active$/) do |tier, years|
  @customer = Customer.new(tier: tier, years_active: years.to_i)
end

Given(/^a cart with total \$(\d+)$/) do |amount|
  @cart = Cart.new(Money.new(amount.to_i))
end

When("the discount is calculated") do
  @discount = PricingEngine.new.calculate_discount(@customer, @cart)
end

Then(/^the discount should be \$(\d+)$/) do |amount|
  expect(@discount).to eq(Money.new(amount.to_i))
end

3. TDD Implementation (Driven by failing step)

# tests/unit/pricing/test_engine.py
def test_gold_customer_3_years_gets_15_percent():
    customer = Customer(tier="gold", years_active=3)
    cart = Cart(Money(1000))

    discount = PricingEngine().calculate_discount(customer, cart)

    assert discount == Money(150)

# Implementation evolves:
class PricingEngine:
    def calculate_discount(self, customer, cart):
        base = self._base_discount(customer.tier, cart.total)
        loyalty = self._loyalty_bonus(customer.years_active)
        return base + loyalty
// tests/unit/pricing/test_engine.cpp
#include <cassert>

void test_gold_customer_3_years_gets_15_percent() {
    Customer customer("gold", 3);
    Cart cart(Money(1000));

    Money discount = PricingEngine().calculate_discount(customer, cart);

    assert(discount == Money(150));
}

class PricingEngine {
public:
    Money calculate_discount(const Customer& customer, const Cart& cart) {
        Money base = _base_discount(customer.tier, cart.total());
        Money loyalty = _loyalty_bonus(customer.years_active);
        return base + loyalty;
    }
private:
    Money _base_discount(const std::string& tier, const Money& total);
    Money _loyalty_bonus(int years_active);
};
// tests/unit/pricing/test_engine.java
import static org.junit.jupiter.api.Assertions.*;

class PricingEngineTest {
    @Test
    void testGoldCustomer3YearsGets15Percent() {
        Customer customer = new Customer("gold", 3);
        Cart cart = new Cart(new Money(1000));

        Money discount = new PricingEngine().calculate_discount(customer, cart);

        assertEquals(new Money(150), discount);
    }
}

public class PricingEngine {
    public Money calculate_discount(Customer customer, Cart cart) {
        Money base = _base_discount(customer.tier, cart.total());
        Money loyalty = _loyalty_bonus(customer.years_active);
        return base.add(loyalty);
    }
}
// tests/unit/pricing/test_engine.cs
using Xunit;

public class PricingEngineTests {
    [Fact]
    public void TestGoldCustomer3YearsGets15Percent() {
        var customer = new Customer("gold", 3);
        var cart = new Cart(new Money(1000));

        var discount = new PricingEngine().CalculateDiscount(customer, cart);

        Assert.Equal(new Money(150), discount);
    }
}

public class PricingEngine {
    public Money CalculateDiscount(Customer customer, Cart cart) {
        var base = _base_discount(customer.Tier, cart.Total);
        var loyalty = _loyalty_bonus(customer.YearsActive);
        return base + loyalty;
    }
}
# spec/pricing/engine_spec.rb
RSpec.describe PricingEngine do
  it "gives gold customers of 3 years a 15% discount" do
    customer = Customer.new(tier: "gold", years_active: 3)
    cart = Cart.new(Money.new(1000))

    discount = described_class.new.calculate_discount(customer, cart)

    expect(discount).to eq(Money.new(150))
  end
end

# Implementation evolves:
class PricingEngine
  def calculate_discount(customer, cart)
    base = base_discount(customer.tier, cart.total)
    loyalty = loyalty_bonus(customer.years_active)
    base + loyalty
  end
end

Common TDD/BDD Pitfalls

Pitfall Symptom Fix
Testing private methods Tests break on refactor Test public API; extract if needed
Over-mocking Brittle tests, false confidence Mock boundaries, not internals
BDD = UI automation Slow, flaky suites BDD = business rules, not UI scripts
No refactoring step Accumulated technical debt Make refactoring a non-negotiable step
Tests as afterthought Tests don't drive design Write test first, always

Advanced Topics

Property-Based Testing

# property-based testing with hypothesis
from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_sort_idempotent(xs):
    assert sorted(sorted(xs)) == sorted(xs)

@given(st.text())
def test_reverse_involution(s):
    assert s[::-1][::-1] == s
// RapidCheck for C++
#include <rapidcheck/rapidcheck.h>

void test_sort_idempotent() {
    rc::check("sort is idempotent", [](const std::vector<int>& xs) {
        auto sorted_once = xs;
        std::sort(sorted_once.begin(), sorted_once.end());
        auto sorted_twice = sorted_once;
        std::sort(sorted_twice.begin(), sorted_twice.end());
        RC_ASSERT(sorted_once == sorted_twice);
    });
}
// jqwik for Java property-based testing
import net.jqwik.api.*;

class PropertyTests {
    @Property
    boolean sortIsIdempotent(@ForAll List<Integer> xs) {
        var sortedOnce = new ArrayList<>(xs);
        Collections.sort(sortedOnce);
        var sortedTwice = new ArrayList<>(sortedOnce);
        Collections.sort(sortedTwice);
        return sortedOnce.equals(sortedTwice);
    }
}
// FsCheck for C#
using FsCheck;
using FsCheck.Xunit;

public class PropertyTests {
    [Property]
    public bool SortIsIdempotent(List<int> xs) {
        var sortedOnce = xs.OrderBy(x => x).ToList();
        var sortedTwice = sortedOnce.OrderBy(x => x).ToList();
        return sortedOnce.SequenceEqual(sortedTwice);
    }
}
# property-based testing with the prop_check gem
require "prop_check"
G = PropCheck::Generators

PropCheck.forall(G.array(G.integer)) do |xs|
  raise "sort is not idempotent" unless xs.sort.sort == xs.sort
end

PropCheck.forall(G.string) do |s|
  raise "reverse is not an involution" unless s.reverse.reverse == s
end

Mutation Testing

# mutmut for Python
# pip install mutmut
# mutmut run --paths-to-mutate=src/

# In CI:
# - mutmut run
# - mutmut results
# - Threshold: >80% mutation score
// Mull for C++
// clang -fprofile-instr-generate -fcoverage-mapping
// mull-cxx --thread-count=4 --mutation-score-threshold=80
// PITest for Java
<plugin>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-maven</artifactId>
    <version>1.15.0</version>
    <configuration>
        <targetClasses>
            <param>com.myapp.*</param>
        </targetClasses>
        <mutationThreshold>80</mutationThreshold>
    </configuration>
</plugin>
// Stryker.NET for C#
// dotnet tool install -g dotnet-stryker
// dotnet stryker --threshold-break 80
# mutant for Ruby
# gem install mutant mutant-rspec
# bundle exec mutant run --integration rspec 'PricingEngine*'

# In CI:
# - bundle exec mutant run
# - mutant reports killed vs alive mutations
# - Threshold: >80% mutation coverage

Contract Testing (Pact)

# Consumer-driven contract testing with Pact
from pact import Consumer, Provider, Given, UponReceiving, WithState

pact = Consumer('OrderService').has_pact_with(Provider('PaymentService'))

(pact
    .given('a valid payment request')
    .upon_receiving('a charge request')
    .with_request('POST', '/payment/charge', body={
        'amount_cents': 1999,
        'currency': 'USD',
        'source': 'tok_visa'
    })
    .will_respond_with(200, body={
        'transaction_id': 'txn_123',
        'status': 'succeeded'
    }))

pact.verify()
// Pact C++ is limited
// Use pact-verifier CLI for verification
# pact-verifier --provider-base-url=http://localhost:8080 \
#   --pact-url=./pacts/order_service-payment_service.json
// Pact JVM
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "PaymentService")
class PaymentContractTest {
    @Test
    @Pact(consumer = "OrderService", provider = "PaymentService")
    V2RequestResponsePact createPact(PactDslWithProvider builder) {
        return builder
            .given("a valid payment request")
            .uponReceiving("a charge request")
            .path("/payment/charge")
            .method("POST")
            .body(newJsonBody(o -> o
                .numberType("amount_cents", 1999)
                .stringType("currency", "USD")
                .stringType("source", "tok_visa")
            ))
            .willRespondWith()
            .status(200)
            .body(newJsonBody(o -> o
                .stringType("transaction_id", "txn_123")
                .stringType("status", "succeeded")
            ))
            .toPact(V2Pact.class);
    }
}
// PactNet
using PactNet;
using Xunit;

public class PaymentContractTests {
    [Fact]
    public void PaymentContract() {
        var config = new PactConfig { PactDir = "./pacts" };
        var pact = new PactVerifier(new PactConfig { PactDir = "./pacts" });

        var builder = new V2PactBuilder("OrderService", "PaymentService");
        builder
            .Given("a valid payment request")
            .UponReceiving("a charge request")
            .With(new ProviderServiceRequest {
                Method = HttpVerb.Post,
                Path = "/payment/charge",
                Body = new { amount_cents = 1999, currency = "USD", source = "tok_visa" }
            })
            .WillRespondWith(200, new {
                transaction_id = "txn_123",
                status = "succeeded"
            });
    }
}
# Consumer-driven contract testing with Pact
require "pact/consumer/rspec"

Pact.service_consumer "OrderService" do
  has_pact_with "PaymentService" do
    mock_service :payment_service do
      port 1234
    end
  end
end

payment_service
  .given("a valid payment request")
  .upon_receiving("a charge request")
  .with(method: :post, path: "/payment/charge",
        body: { amount_cents: 1999, currency: "USD", source: "tok_visa" })
  .will_respond_with(status: 200,
                     body: { transaction_id: "txn_123", status: "succeeded" })

Resources

Books

  • Test Driven Development: By Example — Kent Beck
  • The Cucumber Book — Matt Wynne & Aslak Hellesøy
  • Specification by Example — Gojko Adzic
  • Growing Object-Oriented Software, Guided by Tests — Steve Freeman & Nat Pryce
  • TDD for Embedded C — James W. Grenning

Tools

Language TDD BDD Mutation Contract
Python pytest pytest-bdd, behave mutmut pact-python
C++ Catch2, GoogleTest Cucumber-cpp Mull Pact-cpp
Java JUnit 5 Cucumber JVM, JGiven PITest Pact JVM
C# xUnit, NUnit SpecFlow, BDDfy Stryker.NET PactNet
Go testing Godog, Ginkgo Go-mutesting Pact-go

Talks

  • "TDD: Test Desired Design" by Ian Cooper — NDC
  • "BDD: Beyond the Basics" by Gojko Adzic — NDC
  • "Mutation Testing: The What, Why, and How" by Greg L. Turnquist — Spring I/O
  • "Pact: Consumer Driven Contracts" by Beth Skurrie — Øredev

Summary: TDD/BDD Maturity

Level TDD BDD Tooling Culture
1. Initial Ad-hoc tests None Manual Testing after coding
2. Emerging Unit tests written Basic scenarios Manual runs Tests as afterthought
3. Practising RED-GREEN-REFACTOR Gherkin + Glue CI integration TDD/BDD routine
4. Proficient Property-based + Mutation Specification by Example CI gates + gates Tests drive design
5. Mastery Mutation score >80% Living docs + contracts Zero-touch CI/CD Quality culture

Remember: TDD is a design practice, not a testing practice. BDD is a communication practice, not a testing tool. Together, they ensure you build the right thing and build the thing right.

See BDD as Specification for a real, working case study taking this page's "living documentation" claim one step further — a system that derives an actual rule from Given/When/Then scenarios, and turns an unsatisfiable scenario into a specific, answerable question rather than a bare test failure. TDD itself originated as one of the core engineering practices of Extreme Programming — see Extreme Programming for where it sits alongside pair programming and continuous integration, and Scrum for the process layer that (deliberately) says nothing about engineering practice and leaves this gap for XP to fill.