Files
2025-11-30 08:50:59 +08:00

12 KiB

Test-Driven Development (TDD) Principles

This document provides comprehensive guidance on TDD methodology, the Red-Green-Refactor cycle, and how to apply TDD effectively across different programming contexts.

Core Philosophy

Test-Driven Development is a software development methodology where tests are written before the production code. The fundamental principle is: Write failing tests first, then write code to make them pass.

Why TDD?

  1. Design Pressure: Writing tests first forces you to think about the API design before implementation
  2. Regression Safety: Every feature is protected by tests from the moment it's created
  3. Living Documentation: Tests serve as executable examples of how the code should be used
  4. Confidence to Refactor: Comprehensive test coverage enables safe code improvements
  5. Better Code Structure: TDD naturally produces more modular, testable, and maintainable code

The Red-Green-Refactor Cycle

TDD follows a strict three-phase cycle that must be repeated for every small piece of functionality:

🔴 Red Phase: Write a Failing Test

Purpose: Define the desired behavior before implementation exists.

Process:

  1. Write a test that expresses the desired behavior
  2. Run the test and verify it fails (it must fail for the right reason)
  3. The failure confirms the test is actually testing something

Key Principles:

  • Write the simplest test that expresses one behavior
  • Test should be readable and clearly express intent
  • Focus on behavior, not implementation
  • Use descriptive test names (e.g., test_should_return_empty_list_when_no_items_match)

Example (Python):

# Red Phase: Test written first, will fail
def test_should_calculate_total_price_with_tax():
    cart = ShoppingCart()
    cart.add_item(Item("Book", price=10.00))

    total = cart.calculate_total(tax_rate=0.1)

    assert total == 11.00  # This will fail - method doesn't exist yet

Red Phase Checklist:

  • Test clearly expresses desired behavior?
  • Test name is descriptive and behavior-focused?
  • Test actually fails when run?
  • Failure message is clear about what's missing?

🟢 Green Phase: Make the Test Pass

Purpose: Write the minimal code necessary to make the failing test pass.

Process:

  1. Write just enough production code to make the test pass
  2. Take shortcuts if needed (you'll refactor later)
  3. Run the test and verify it passes
  4. Don't add extra features or abstractions yet

Key Principles:

  • Write the simplest implementation that makes the test pass
  • Don't worry about code quality yet (that's the refactor phase)
  • Resist the urge to add features not tested
  • It's okay to hard-code values initially (refactor will generalize)

Example (Python):

# Green Phase: Minimal implementation to pass the test
class ShoppingCart:
    def __init__(self):
        self.items = []

    def add_item(self, item):
        self.items.append(item)

    def calculate_total(self, tax_rate):
        # Minimal implementation - just make it work
        subtotal = sum(item.price for item in self.items)
        return subtotal * (1 + tax_rate)

class Item:
    def __init__(self, name, price):
        self.name = name
        self.price = price

Green Phase Checklist:

  • Test now passes?
  • Only added code necessary to pass the test?
  • No extra features or premature optimization?
  • All previously passing tests still pass?

🔵 Refactor Phase: Improve the Code

Purpose: Improve code quality while maintaining passing tests.

Process:

  1. Look for duplication, poor names, or structural issues
  2. Refactor the code to improve quality
  3. Run tests after each small change to ensure they still pass
  4. Continue until code is clean and maintainable

Key Principles:

  • Never refactor with failing tests
  • Make small, incremental changes
  • Run tests frequently during refactoring
  • Improve both test code and production code
  • Apply design patterns and best practices

Example (Python):

# Refactor Phase: Improve structure and readability
class ShoppingCart:
    def __init__(self):
        self._items = []

    def add_item(self, item):
        self._items.append(item)

    def calculate_total(self, tax_rate=0.0):
        subtotal = self._calculate_subtotal()
        tax_amount = subtotal * tax_rate
        return subtotal + tax_amount

    def _calculate_subtotal(self):
        return sum(item.price for item in self._items)


class Item:
    def __init__(self, name, price):
        if price < 0:
            raise ValueError("Price cannot be negative")
        self.name = name
        self.price = price

Refactor Phase Checklist:

  • Code is DRY (Don't Repeat Yourself)?
  • Names are clear and expressive?
  • Functions/methods are focused and small?
  • Code follows SOLID principles?
  • All tests still pass?
  • Test code is also clean and maintainable?

TDD Rhythm and Cadence

The Cycle Duration

A complete Red-Green-Refactor cycle should be very short - typically 2-10 minutes:

  • Red: 1-2 minutes to write a small test
  • Green: 1-5 minutes to make it pass
  • Refactor: 1-3 minutes to clean up

If cycles are taking longer, the tests are too large. Break them into smaller pieces.

Commit Strategy

Commit at the end of each complete Red-Green-Refactor cycle:

  • After Red: Don't commit failing tests
  • After Green: Can commit if needed, but prefer to refactor first
  • After Refactor: Commit here - you have working, clean code with tests

Incremental Development

Build features incrementally, one test at a time:

Bad (Big Bang):

Write comprehensive test suite → Implement entire feature → Debug for hours

Good (Incremental):

Test 1 → Implement 1 → Refactor →
Test 2 → Implement 2 → Refactor →
Test 3 → Implement 3 → Refactor → ...

TDD Best Practices

1. Test Behavior, Not Implementation

Bad:

def test_uses_quicksort_algorithm():
    sorter = Sorter()
    # Testing internal implementation detail
    assert sorter._partition_method == "quicksort"

Good:

def test_should_return_sorted_list_in_ascending_order():
    sorter = Sorter()
    unsorted = [3, 1, 4, 1, 5]

    result = sorter.sort(unsorted)

    assert result == [1, 1, 3, 4, 5]

2. One Assertion Per Test (Usually)

Bad:

def test_user_registration():
    user = register_user("john@example.com", "password123")

    assert user.email == "john@example.com"
    assert user.is_active == True
    assert user.created_at is not None
    assert user.id is not None
    # Too many concerns in one test

Good:

def test_should_create_user_with_provided_email():
    user = register_user("john@example.com", "password123")
    assert user.email == "john@example.com"

def test_should_create_active_user_by_default():
    user = register_user("john@example.com", "password123")
    assert user.is_active == True

def test_should_assign_creation_timestamp_to_new_user():
    user = register_user("john@example.com", "password123")
    assert user.created_at is not None

3. Arrange-Act-Assert (AAA) Pattern

Structure every test with three clear sections:

def test_should_withdraw_amount_from_account_balance():
    # Arrange: Set up test data and preconditions
    account = Account(initial_balance=100)

    # Act: Execute the behavior being tested
    account.withdraw(30)

    # Assert: Verify the expected outcome
    assert account.balance == 70

4. Test Names Should Be Descriptive

Bad: test_user(), test_1(), test_validation()

Good:

  • test_should_reject_user_registration_with_invalid_email()
  • test_should_return_empty_list_when_database_has_no_records()
  • test_should_throw_exception_when_withdrawal_exceeds_balance()

5. Keep Tests Fast

  • Unit tests should run in milliseconds
  • Avoid file I/O, network calls, and databases in unit tests
  • Use mocks/stubs for external dependencies
  • Slow tests discourage running them frequently

6. Keep Tests Independent

Each test should be able to run in isolation:

  • Don't rely on test execution order
  • Don't share state between tests
  • Use setup/teardown to create clean state for each test

Common TDD Mistakes

Mistake 1: Writing Tests After Code

Problem: Writing tests after implementation defeats the purpose of TDD.

Why it matters: You lose the design benefits of TDD and tests become implementation-focused rather than behavior-focused.

Solution: Discipline. Always write the test first, even if it feels slower initially.

Mistake 2: Tests That Are Too Large

Problem: Writing comprehensive tests that cover too much functionality.

Why it matters: Large tests lead to large implementations, losing the incremental nature of TDD.

Solution: Break down tests into smallest possible behavioral units. If a test takes more than 5 minutes to implement, it's too big.

Mistake 3: Skipping the Refactor Phase

Problem: Moving to the next test immediately after green without refactoring.

Why it matters: Technical debt accumulates quickly, leading to unmaintainable code.

Solution: Always spend time in the refactor phase. Code quality is not optional.

Mistake 4: Testing Implementation Details

Problem: Tests that check how code works internally rather than what it produces.

Why it matters: Implementation-focused tests are fragile and prevent refactoring.

Solution: Focus tests on public interfaces and observable behavior.

Mistake 5: Incomplete Red Phase

Problem: Not running the test to verify it actually fails.

Why it matters: You might write a test that always passes (false positive).

Solution: Always verify the test fails before implementing. Watch it turn from red to green.

TDD in Different Contexts

Unit Testing (Primary TDD Focus)

  • Test individual functions/methods in isolation
  • Fast execution (milliseconds)
  • Mock external dependencies
  • High coverage of edge cases

Integration Testing (TDD Can Apply)

  • Test interactions between components
  • Slower execution (seconds)
  • Use real dependencies where practical
  • Focus on critical integration points

Acceptance Testing (BDD/TDD Hybrid)

  • Test complete user scenarios
  • Written in behavior-focused language
  • Slower execution (seconds to minutes)
  • Validate business requirements

Measuring TDD Effectiveness

Good TDD Indicators

  1. Test Count: Tests outnumber production code files
  2. Test Coverage: High line/branch coverage (>80%)
  3. Commit Frequency: Small, frequent commits
  4. Code Structure: Small functions, low complexity
  5. Refactoring Confidence: Easy to change code without fear

Poor TDD Indicators

  1. Test-After Pattern: Tests written in bulk after features
  2. Low Coverage: Large portions of code untested
  3. Complex Code: Long methods, deep nesting
  4. Fragile Tests: Tests break with minor refactoring
  5. Fear of Change: Reluctance to modify code

TDD and Code Quality

Code Characteristics of TDD

Well-executed TDD produces code with:

Small Functions: Typically 5-20 lines Flat Structure: Minimal nesting (1-2 levels max) Single Responsibility: Each function does one thing Clear Naming: Descriptive names that explain purpose Low Coupling: Components loosely connected High Cohesion: Related functionality grouped together Testable Design: Easy to test in isolation

Code Smells Indicating Test-After Development

Long Methods: Functions over 30 lines Deep Nesting: 3+ levels of if/else/for statements Complex Conditionals: Multiple AND/OR in one condition Type Checking: Using isinstance() or typeof God Objects: Classes with too many responsibilities Tight Coupling: Components highly dependent on each other Poor Naming: Generic names like data, process, manager

Summary: The TDD Mindset

TDD is not just about testing - it's a design methodology:

  1. Tests Drive Design: Let test requirements shape your API
  2. Incremental Progress: Build features one small test at a time
  3. Continuous Refinement: Always improve code quality through refactoring
  4. Fast Feedback: Run tests constantly to catch issues immediately
  5. Confidence: Trust your tests to enable bold refactoring

Remember: Red-Green-Refactor is not optional. Follow the cycle religiously, and your code quality will improve dramatically.