Test-Driven Development and Behavior-Driven Development

Test-driven development (TDD) fundamentally changes how you approach coding. Instead of writing code and then testing it, you write tests first and let them guide your implementation. I was skeptical of TDD until I experienced how it forces you to think about design upfront and creates more maintainable code.

The TDD cycle—red, green, refactor—seems simple but requires discipline. You write a failing test (red), make it pass with minimal code (green), then improve the code while keeping tests passing (refactor). This process leads to better-designed, more testable code.

The TDD Red-Green-Refactor Cycle

Let’s build a simple calculator using TDD to demonstrate the process. We start with the simplest possible test:

import pytest
from calculator import Calculator  # This doesn't exist yet

def test_calculator_creation():
    """Test that we can create a calculator instance."""
    calc = Calculator()
    assert calc is not None

This test fails because Calculator doesn’t exist (red phase). Now we write the minimal code to make it pass:

# calculator.py
class Calculator:
    pass

The test passes (green phase). Now we add the next test:

def test_calculator_add_two_numbers():
    """Test adding two numbers."""
    calc = Calculator()
    result = calc.add(2, 3)
    assert result == 5

This fails because add() doesn’t exist. We implement it:

class Calculator:
    def add(self, a, b):
        return a + b

The test passes. We continue this cycle, adding more functionality:

def test_calculator_subtract():
    """Test subtracting two numbers."""
    calc = Calculator()
    result = calc.subtract(5, 3)
    assert result == 2

def test_calculator_multiply():
    """Test multiplying two numbers."""
    calc = Calculator()
    result = calc.multiply(4, 3)
    assert result == 12

def test_calculator_divide():
    """Test dividing two numbers."""
    calc = Calculator()
    result = calc.divide(10, 2)
    assert result == 5.0

def test_calculator_divide_by_zero():
    """Test division by zero raises appropriate error."""
    calc = Calculator()
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        calc.divide(10, 0)

Each test drives the implementation forward, ensuring we only write code that’s actually needed.

TDD for Complex Business Logic

TDD shines when implementing complex business rules. Let’s build a discount calculator for an e-commerce system:

def test_no_discount_for_small_orders():
    """Orders under $50 get no discount."""
    calculator = DiscountCalculator()
    discount = calculator.calculate_discount(order_total=30, customer_type="regular")
    assert discount == 0

def test_regular_customer_discount():
    """Regular customers get 5% discount on orders over $50."""
    calculator = DiscountCalculator()
    discount = calculator.calculate_discount(order_total=100, customer_type="regular")
    assert discount == 5.0  # 5% of $100

def test_premium_customer_discount():
    """Premium customers get 10% discount on orders over $50."""
    calculator = DiscountCalculator()
    discount = calculator.calculate_discount(order_total=100, customer_type="premium")
    assert discount == 10.0  # 10% of $100

def test_bulk_order_additional_discount():
    """Orders over $500 get additional 5% discount."""
    calculator = DiscountCalculator()
    discount = calculator.calculate_discount(order_total=600, customer_type="regular")
    assert discount == 60.0  # 5% base + 5% bulk = 10% of $600

These tests define the business rules clearly before any implementation exists. The implementation emerges from the requirements:

class DiscountCalculator:
    def calculate_discount(self, order_total, customer_type):
        if order_total < 50:
            return 0
        
        base_discount_rate = 0.05 if customer_type == "regular" else 0.10
        
        # Additional discount for bulk orders
        bulk_discount_rate = 0.05 if order_total > 500 else 0
        
        total_discount_rate = base_discount_rate + bulk_discount_rate
        return order_total * total_discount_rate

The tests serve as both specification and verification, making the business logic explicit and testable.

Behavior-Driven Development with pytest-bdd

BDD extends TDD by using natural language to describe behavior. This makes tests readable by non-technical stakeholders and helps ensure you’re building the right thing:

# Install: pip install pytest-bdd

# features/calculator.feature
"""
Feature: Calculator Operations
  As a user
  I want to perform basic arithmetic operations
  So that I can calculate results accurately

  Scenario: Adding two positive numbers
    Given I have a calculator
    When I add 2 and 3
    Then the result should be 5

  Scenario: Dividing by zero
    Given I have a calculator
    When I divide 10 by 0
    Then I should get a division by zero error
"""

# test_calculator_bdd.py
from pytest_bdd import scenarios, given, when, then, parsers
import pytest

scenarios('features/calculator.feature')

@given('I have a calculator')
def calculator():
    return Calculator()

@when(parsers.parse('I add {num1:d} and {num2:d}'))
def add_numbers(calculator, num1, num2):
    calculator.result = calculator.add(num1, num2)

@when(parsers.parse('I divide {num1:d} by {num2:d}'))
def divide_numbers(calculator, num1, num2):
    try:
        calculator.result = calculator.divide(num1, num2)
    except ValueError as e:
        calculator.error = e

@then(parsers.parse('the result should be {expected:d}'))
def check_result(calculator, expected):
    assert calculator.result == expected

@then('I should get a division by zero error')
def check_division_error(calculator):
    assert hasattr(calculator, 'error')
    assert "Cannot divide by zero" in str(calculator.error)

BDD scenarios read like specifications and can be understood by product managers, QA engineers, and developers alike.

TDD for API Development

TDD works excellently for API development, helping you design clean interfaces:

def test_create_user_endpoint():
    """Test creating a new user via API."""
    client = TestClient(app)
    
    response = client.post("/users", json={
        "username": "alice",
        "email": "[email protected]",
        "password": "secure_password"
    })
    
    assert response.status_code == 201
    data = response.json()
    assert data["username"] == "alice"
    assert data["email"] == "[email protected]"
    assert "password" not in data  # Password should not be returned
    assert "id" in data

def test_create_user_duplicate_username():
    """Test creating user with duplicate username fails."""
    client = TestClient(app)
    
    # Create first user
    client.post("/users", json={
        "username": "bob",
        "email": "[email protected]",
        "password": "password"
    })
    
    # Try to create duplicate
    response = client.post("/users", json={
        "username": "bob",
        "email": "[email protected]",
        "password": "password"
    })
    
    assert response.status_code == 400
    assert "username already exists" in response.json()["detail"]

def test_get_user_by_id():
    """Test retrieving user by ID."""
    client = TestClient(app)
    
    # Create user first
    create_response = client.post("/users", json={
        "username": "charlie",
        "email": "[email protected]",
        "password": "password"
    })
    user_id = create_response.json()["id"]
    
    # Retrieve user
    response = client.get(f"/users/{user_id}")
    
    assert response.status_code == 200
    data = response.json()
    assert data["username"] == "charlie"
    assert data["email"] == "[email protected]"

These tests drive the API design, ensuring consistent behavior and proper error handling.

TDD Refactoring Phase

The refactoring phase is where TDD’s real value emerges. With comprehensive tests, you can improve code structure without fear of breaking functionality:

# Initial implementation (works but not optimal)
class OrderProcessor:
    def process_order(self, order_data):
        # Validate order
        if not order_data.get('items'):
            raise ValueError("Order must contain items")
        
        # Calculate total
        total = 0
        for item in order_data['items']:
            total += item['price'] * item['quantity']
        
        # Apply discount
        if order_data.get('customer_type') == 'premium':
            total *= 0.9  # 10% discount
        
        # Process payment
        if total > 1000:
            # Special handling for large orders
            payment_result = self.process_large_payment(total)
        else:
            payment_result = self.process_regular_payment(total)
        
        return {
            'order_id': self.generate_order_id(),
            'total': total,
            'payment_status': payment_result
        }

# Refactored implementation (better separation of concerns)
class OrderProcessor:
    def __init__(self, validator, calculator, payment_processor):
        self.validator = validator
        self.calculator = calculator
        self.payment_processor = payment_processor
    
    def process_order(self, order_data):
        self.validator.validate_order(order_data)
        
        total = self.calculator.calculate_total(order_data)
        payment_result = self.payment_processor.process_payment(total)
        
        return {
            'order_id': self.generate_order_id(),
            'total': total,
            'payment_status': payment_result
        }

The tests ensure that refactoring doesn’t break existing functionality while improving code maintainability.

Common TDD Pitfalls and Solutions

Avoid these common TDD mistakes that can make the practice less effective:

# Bad: Testing implementation details
def test_user_service_calls_database_save():
    """This test is too coupled to implementation."""
    mock_db = Mock()
    service = UserService(mock_db)
    
    service.create_user("alice", "[email protected]")
    
    # This breaks if we change internal implementation
    mock_db.save.assert_called_once()

# Good: Testing behavior
def test_user_service_creates_user():
    """This test focuses on behavior, not implementation."""
    mock_db = Mock()
    mock_db.save.return_value = User(id=1, username="alice")
    service = UserService(mock_db)
    
    user = service.create_user("alice", "[email protected]")
    
    assert user.username == "alice"
    assert user.id is not None

Focus on testing behavior and outcomes rather than internal implementation details.

When TDD Works Best

TDD excels for complex business logic, APIs, and algorithms where requirements are clear. It’s less effective for exploratory coding, UI development, or when you’re learning new technologies and need to experiment.

Use TDD when you understand the problem domain and can articulate expected behavior. Skip it when you’re prototyping or exploring solutions, but return to TDD once you understand what you’re building.

In our next part, we’ll explore code coverage analysis and quality metrics. We’ll learn how to measure test effectiveness, identify untested code paths, and use metrics to improve your testing strategy without falling into the trap of chasing meaningless coverage percentages.