Contents

TDD with Python

Introduction

Test-Driven Development (TDD) is one of the most powerful practices a developer can adopt. After 10+ years in software development, I’ve seen firsthand how TDD transforms not just code quality, but the entire development process.

In this post, I’ll share practical insights on implementing TDD in Python projects, based on my experience building products from 0→1 and scaling them to enterprise level.

What is TDD?

TDD follows a simple three-step cycle:

The TDD Cycle
  1. Red - Write a failing test
  2. Green - Write minimal code to pass the test
  3. Refactor - Improve the code while keeping tests green

Why TDD Matters in Startups

Having worked extensively with startups, I’ve learned that TDD is crucial when:

  • Building MVPs that need to evolve quickly
  • Scaling teams and onboarding new developers
  • Refactoring legacy systems
  • Ensuring reliability under rapid growth

A Practical Python Example

Let’s build a simple user authentication system using TDD:

Step 1: Write the Test First

# test_auth.py
import pytest
from auth import UserAuthenticator, InvalidCredentialsError

class TestUserAuthenticator:
    def setup_method(self):
        self.auth = UserAuthenticator()
    
    def test_successful_login(self):
        # Arrange
        username = "testuser"
        password = "securepassword"
        self.auth.register_user(username, password)
        
        # Act
        result = self.auth.login(username, password)
        
        # Assert
        assert result.is_authenticated is True
        assert result.username == username
    
    def test_login_with_invalid_password(self):
        # Arrange
        username = "testuser"
        password = "securepassword"
        wrong_password = "wrongpassword"
        self.auth.register_user(username, password)
        
        # Act & Assert
        with pytest.raises(InvalidCredentialsError):
            self.auth.login(username, wrong_password)

Step 2: Write Minimal Implementation

# auth.py
import hashlib
from dataclasses import dataclass
from typing import Dict

class InvalidCredentialsError(Exception):
    """Raised when login credentials are invalid"""
    pass

@dataclass
class LoginResult:
    is_authenticated: bool
    username: str

class UserAuthenticator:
    def __init__(self):
        self._users: Dict[str, str] = {}
    
    def register_user(self, username: str, password: str) -> None:
        """Register a new user with hashed password"""
        hashed_password = self._hash_password(password)
        self._users[username] = hashed_password
    
    def login(self, username: str, password: str) -> LoginResult:
        """Authenticate user and return login result"""
        if username not in self._users:
            raise InvalidCredentialsError("Invalid username or password")
        
        hashed_password = self._hash_password(password)
        if self._users[username] != hashed_password:
            raise InvalidCredentialsError("Invalid username or password")
        
        return LoginResult(is_authenticated=True, username=username)
    
    def _hash_password(self, password: str) -> str:
        """Hash password using SHA-256"""
        return hashlib.sha256(password.encode()).hexdigest()

Step 3: Run the Tests

pytest test_auth.py -v

Key TDD Benefits I’ve Observed

1. Faster Development Cycles

  • Immediate feedback on code changes
  • Reduced debugging time
  • Confident refactoring

2. Better Code Design

  • Forces you to think about interfaces first
  • Naturally leads to modular, testable code
  • Prevents over-engineering

3. Living Documentation

  • Tests serve as executable specifications
  • New team members understand requirements quickly
  • API contracts are clearly defined

Common TDD Challenges & Solutions

Challenge 1: “TDD is Too Slow”

Reality: TDD feels slower initially but pays dividends in:

  • Reduced bug fixing time
  • Faster feature additions
  • Easier maintenance

Challenge 2: “What to Test?”

Focus on:

  • Public API contracts
  • Business logic
  • Edge cases and error conditions

Avoid testing:

  • Implementation details
  • Third-party library functionality
  • Simple getters/setters

Challenge 3: “Mocking is Complex”

# Use dependency injection for easier testing
class EmailService:
    def send_email(self, to: str, subject: str, body: str) -> bool:
        # Implementation here
        pass

class UserRegistration:
    def __init__(self, email_service: EmailService):
        self.email_service = email_service
    
    def register(self, username: str, email: str) -> bool:
        # Registration logic
        return self.email_service.send_email(
            email, 
            "Welcome!", 
            f"Welcome {username}!"
        )

# In tests, inject a mock
def test_user_registration_sends_email():
    mock_email_service = Mock()
    mock_email_service.send_email.return_value = True
    
    registration = UserRegistration(mock_email_service)
    result = registration.register("testuser", "test@example.com")
    
    assert result is True
    mock_email_service.send_email.assert_called_once()

Tools and Setup

Essential Python Testing Tools:

pip install pytest pytest-cov pytest-mock
project/
├── src/
│   └── myproject/
│       ├── __init__.py
│       └── auth.py
├── tests/
│   ├── __init__.py
│   ├── test_auth.py
│   └── conftest.py
├── pytest.ini
└── requirements-dev.txt

pytest Configuration (pytest.ini):

[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = 
    --strict-markers
    --strict-config
    --verbose
    --cov=src
    --cov-report=html
    --cov-report=term-missing

Real-World TDD Tips

1. Start Small

Begin with simple functions and gradually apply TDD to more complex systems.

2. Keep Tests Simple

Each test should verify one specific behavior.

3. Use Descriptive Test Names

def test_login_fails_when_user_does_not_exist(self):
def test_registration_sends_welcome_email_to_new_user(self):
def test_password_must_be_at_least_8_characters(self):

4. Follow the AAA Pattern

  • Arrange: Set up test data
  • Act: Execute the behavior
  • Assert: Verify the outcome

Conclusion

TDD isn’t just about testing—it’s a design methodology that leads to better software architecture. In my experience building products from concept to scale, TDD has been instrumental in:

  • Maintaining code quality during rapid development
  • Enabling confident refactoring of legacy systems
  • Onboarding new team members effectively
  • Building trust with stakeholders through reliable deliveries
Getting Started
Pick a small feature in your current project and try the red-green-refactor cycle. Start with simple unit tests and gradually incorporate TDD into your workflow.

The initial investment in learning TDD pays enormous dividends in code quality, development speed, and team confidence.


What’s your experience with TDD? I’d love to hear about your challenges and successes in the comments below!

Further Reading

Want to discuss TDD strategies for your startup? Reach out - I love talking shop about engineering practices!