โš™๏ธ Software Engineering

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 behavior] 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 behavior 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());
}

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)

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
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
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
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);
    }
}

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;
    }
}

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);
    }
}

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

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"
            });
    }
}

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. Practicing 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.