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

392 lines
12 KiB
Markdown

# 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)**:
```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)**:
```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)**:
```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**:
```python
def test_uses_quicksort_algorithm():
sorter = Sorter()
# Testing internal implementation detail
assert sorter._partition_method == "quicksort"
```
**Good**:
```python
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**:
```python
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**:
```python
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:
```python
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.