Test-Driven Development (TDD)
When to use this skill
- Writing new features or functionality from scratch
- Fixing bugs with regression tests to prevent recurrence
- Refactoring existing code safely with test coverage
- Adding test coverage to untested legacy code
- Ensuring code behaves as expected before implementation
- Improving code design through testability constraints
- Documenting expected behavior and edge cases
- Building critical business logic that must be correct
- Developing APIs or libraries with clear contracts
- Working on projects requiring high reliability
- Implementing complex algorithms or business rules
- Collaborating in teams where tests serve as documentation
Implement the red-green-refactor cycle for reliable, testable code.
TDD Cycle
1. RED - Write a Failing Test
Write a test that describes the desired behavior BEFORE implementing:
def test_user_can_register():
"""Test that a user can successfully register with valid data."""
result = register_user(email="test@example.com", password="secure123")
assert result.success is True
assert result.user.email == "test@example.com"
Why this works:
- Forces clear requirements thinking
- Catches specification issues early
- Provides immediate feedback on implementation
- Creates living documentation
2. GREEN - Write Minimal Code to Pass
Implement ONLY what's needed to make the test pass:
def register_user(email: str, password: str) -> RegistrationResult:
"""Register a new user with email and password."""
user = User(email=email)
return RegistrationResult(success=True, user=user)
Resist the urge to:
- Add features not covered by tests
- Over-engineer the solution
- Optimize prematurely
3. REFACTOR - Improve Code Quality
With tests passing, improve the code structure:
def register_user(email: str, password: str) -> RegistrationResult:
"""Register a new user with email and password."""
validate_email(email)
validate_password(password)
hashed_password = hash_password(password)
user = User(email=email, password_hash=hashed_password)
user.save()
return RegistrationResult(success=True, user=user)
Refactor for:
- Better naming
- Removed duplication
- Improved structure
- Enhanced readability
Best Practices
Start with the Simplest Test
# Good - Start simple
def test_add_returns_sum():
assert add(2, 3) == 5
# Avoid - Don't start with edge cases
def test_add_handles_overflow_with_large_numbers():
assert add(sys.maxsize, 1) == expected_overflow_behavior
Test One Thing at a Time
# Good - Single concern
def test_user_registration_creates_user():
result = register_user("test@example.com", "pass123")
assert result.user is not None
def test_user_registration_hashes_password():
result = register_user("test@example.com", "pass123")
assert result.user.password_hash != "pass123"
# Avoid - Multiple assertions
def test_user_registration():
result = register_user("test@example.com", "pass123")
assert result.user is not None
assert result.user.password_hash != "pass123"
assert result.user.email == "test@example.com"
assert result.success is True
Use Descriptive Test Names
# Good - Describes behavior
def test_invalid_email_returns_validation_error()
def test_duplicate_email_raises_already_exists_error()
def test_successful_registration_sends_welcome_email()
# Avoid - Vague names
def test_register()
def test_email()
def test_validation()
Follow the Three A's Pattern
def test_user_can_update_profile():
# Arrange - Set up test data
user = create_test_user(email="test@example.com")
# Act - Execute the operation
result = user.update_profile(name="John Doe", bio="Developer")
# Assert - Verify the outcome
assert result.success is True
assert user.name == "John Doe"
assert user.bio == "Developer"
Common Patterns
Testing Exceptions
def test_registration_with_invalid_email_raises_error():
with pytest.raises(ValidationError) as exc:
register_user(email="invalid", password="pass123")
assert "valid email" in str(exc.value)
Testing Async Code
@pytest.mark.asyncio
async def test_async_user_registration():
result = await register_user_async("test@example.com", "pass123")
assert result.success is True
Using Fixtures
@pytest.fixture
def test_user():
return User(email="test@example.com", name="Test User")
def test_user_can_login(test_user):
result = login(test_user.email, "correct_password")
assert result.success is True
Mocking External Dependencies
def test_user_registration_sends_email(mocker):
# Mock the email service
mock_send = mocker.patch('services.email.send_welcome_email')
register_user("test@example.com", "pass123")
# Verify email was sent
mock_send.assert_called_once_with("test@example.com")
Verification Checklist
Before completing TDD implementation:
- Test written BEFORE implementation
- Test fails initially (RED phase confirmed)
- Minimal code added to pass test (GREEN phase)
- Code refactored while keeping tests green
- Test names clearly describe behavior
- Each test focuses on one behavior
- No untested code paths remain
- All tests pass consistently
- Code is readable and maintainable
Benefits of TDD
- Better Design - Writing tests first leads to more modular, testable code
- Confidence - Comprehensive test suite catches regressions
- Documentation - Tests serve as living documentation
- Faster Debugging - Failures pinpoint exact issues
- Reduced Bugs - Edge cases caught during development
When NOT to Use Strict TDD
- Rapid prototyping/proof of concepts
- UI layout experimentation
- Exploratory coding (learning new APIs)
- Trivial getter/setter methods
For these cases, write tests after implementation but before committing.
Integration with Development Workflow
# TDD development loop
git checkout -b feature/user-registration
# 1. Write failing test
# 2. Run tests (should fail)
pytest tests/test_registration.py
# 3. Implement minimal code
# 4. Run tests (should pass)
pytest tests/test_registration.py
# 5. Refactor
# 6. Run tests (should still pass)
pytest tests/test_registration.py
# Commit when all tests pass
git add .
git commit -m "feat: implement user registration with TDD"
References
- Kent Beck - "Test-Driven Development: By Example"
- Martin Fowler - "Refactoring: Improving the Design of Existing Code"
- pytest documentation
- unittest documentation
