Skip to content
intermediate Phase 5 · Python Testing

Test-Driven Development

Practice TDD workflow — red, green, refactor cycle for reliable code.

1h
0 problems
Topic Progress 0%

The TDD Cycle

Red-Green-Refactor

1. RED:   Write a failing test
2. GREEN: Write minimal code to pass
3. REFACTOR: Clean up while tests pass

Example: FizzBuzz

# Step 1: Write failing test
# test_fizzbuzz.py
def test_fizzbuzz_returns_number_as_string():
    assert fizzbuzz(1) == '1'

# Run test: FAILS (function doesn't exist)
# Step 2: Make it pass
def fizzbuzz(n):
    return str(n)

# Run test: PASSES
# Step 3: Refactor (not needed yet)

# Step 4: Write next test
def test_fizzbuzz_returns_fizz_for_3():
    assert fizzbuzz(3) == 'Fizz'

# Run test: FAILS
# Step 5: Make it pass
def fizzbuzz(n):
    if n % 3 == 0:
        return 'Fizz'
    return str(n)

# Continue cycle...
def test_fizzbuzz_returns_buzz_for_5():
    assert fizzbuzz(5) == 'Buzz'

def test_fizzbuzz_returns_fizzbuzz_for_15():
    assert fizzbuzz(15) == 'FizzBuzz'

Benefits of TDD

  1. Better design: Forces you to think about interface first
  2. Confidence: Changes don't break existing behavior
  3. Documentation: Tests show how code is used
  4. Debugging: Failure tells you exactly what broke
  5. Refactoring: Safe to improve code structure

When to Use TDD

# ✅ Good for:
# - Complex business logic
# - Bug fixes (write regression test first)
# - API design
# - Algorithms
# - Critical systems

# ❌ Less useful for:
# - Simple CRUD
# - UI code
# - Exploration/prototyping
# - Configuration

TDD in Practice

Example: String Calculator

# Step 1: Write tests first
def test_empty_string():
    assert add('') == 0

def test_single_number():
    assert add('1') == 1

def test_two_numbers():
    assert add('1,2') == 3

def test_multiple_numbers():
    assert add('1,2,3,4,5') == 15

def test_newline_separator():
    assert add('1\n2,3') == 6

def test_custom_separator():
    assert add('//;\n1;2') == 3

def test_negative_numbers():
    with pytest.raises(ValueError) as exc:
        add('-1,-2')
    assert 'negatives not allowed' in str(exc.value)
    assert '-1' in str(exc.value)
    assert '-2' in str(exc.value)

# Step 2: Implement incrementally
def add(numbers: str) -> int:
    if not numbers:
        return 0
    
    # Handle custom separator
    if numbers.startswith('//'):
        separator = numbers[2]
        numbers = numbers[4:]
    else:
        separator = ','
    
    # Replace newlines with separator
    numbers = numbers.replace('\n', separator)
    
    # Parse and validate
    nums = [int(n) for n in numbers.split(separator)]
    negatives = [n for n in nums if n < 0]
    if negatives:
        raise ValueError(f'negatives not allowed: {negatives}')
    
    return sum(nums)

Refactoring with Tests

# Original (passes tests but messy)
def add(numbers: str) -> int:
    if not numbers:
        return 0
    if numbers.startswith('//'):
        sep = numbers[2]
        nums = numbers[4:].replace('\n', sep).split(sep)
    else:
        nums = numbers.replace('\n', ',').split(',')
    ints = []
    for n in nums:
        ints.append(int(n))
    neg = []
    for i in ints:
        if i < 0:
            neg.append(i)
    if len(neg) > 0:
        raise ValueError('negatives not allowed: ' + str(neg))
    total = 0
    for i in ints:
        total = total + i
    return total

# Refactored (same tests pass)
def add(numbers: str) -> int:
    if not numbers:
        return 0
    
    separator = _get_separator(numbers)
    numbers = _clean_numbers(numbers, separator)
    nums = [int(n) for n in numbers.split(separator)]
    
    _validate_no_negatives(nums)
    return sum(nums)

def _get_separator(numbers: str) -> str:
    if numbers.startswith('//'):
        return numbers[2]
    return ','

def _clean_numbers(numbers: str, separator: str) -> str:
    if numbers.startswith('//'):
        numbers = numbers[4:]
    return numbers.replace('\n', separator)

def _validate_no_negatives(nums: list[int]) -> None:
    negatives = [n for n in nums if n < 0]
    if negatives:
        raise ValueError(f'negatives not allowed: {negatives}')

TDD Best Practices

Test Naming

# Use descriptive names
def test_add_returns_sum_of_two_positive_numbers():
    assert add(2, 3) == 5

def test_add_handles_negative_numbers():
    assert add(-1, -2) == -3

# Pattern: test_<what>_<condition>_<expected>
def test_calculate_discount_for_vip_customer_returns_20_percent():
    customer = Customer(is_vip=True)
    assert calculate_discount(customer, 100) == 20

One Assertion Per Test

# ❌ Bad: Multiple unrelated assertions
def test_user():
    user = User('Alice', 30)
    assert user.name == 'Alice'
    assert user.age == 30
    assert user.email is None
    assert user.is_active == True

# ✅ Good: One concept per test
def test_user_name():
    user = User('Alice', 30)
    assert user.name == 'Alice'

def test_user_age():
    user = User('Alice', 30)
    assert user.age == 30

Test Structure (AAA Pattern)

def test_order_total():
    # Arrange
    order = Order()
    order.add_item('apple', 1.00, 3)
    order.add_item('banana', 0.50, 2)
    
    # Act
    total = order.calculate_total()
    
    # Assert
    assert total == 4.00

F.I.R.S.T. Principles

# Fast - Tests should run quickly
def test_simple_calculation():  # Fast
    assert add(1, 2) == 3

# Independent - Tests don't depend on each other
# ❌ Bad
def test_create_user():
    user = create_user('Alice')
    globals()['user_id'] = user.id

def test_get_user():
    user = get_user(globals()['user_id'])
    assert user.name == 'Alice'

# ✅ Good
def test_get_user():
    user = create_user('Alice')
    fetched = get_user(user.id)
    assert fetched.name == 'Alice'

# Repeatable - Same result every time
# ❌ Bad
def test_with_random():
    assert process(random.random())  # Unpredictable

# ✅ Good
def test_with_seed():
    random.seed(42)
    assert process(random.random())  # Deterministic

# Self-validating - Clear pass/fail
# ❌ Bad
def test_output():
    result = calculate(1, 2)
    print(result)  # No assertion!

# ✅ Good
def test_output():
    assert calculate(1, 2) == 3

# Timely - Written at right time (ideally before code)

Testing Edge Cases

# Test boundary conditions
def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

def test_divide_zero():
    assert divide(0, 5) == 0

def test_divide_negative():
    assert divide(-10, 2) == -5

def test_divide_floats():
    assert divide(1, 3) == pytest.approx(0.333, rel=1e-2)