Test-Driven Development in Practice
Test-Driven Development (TDD) is a software development practice where you write tests before you write the production code. The cycle is simple but transformative: Red — write a failing test, Green — write the minimal code to make it pass, Refactor — clean up both the test and production code without changing behavior. This Red-Green-Refactor loop typically runs every few minutes, producing a steady cadence of small, validated increments. TDD leads to better-designed code, comprehensive test coverage, and a reliable safety net for refactoring.
The Red-Green-Refactor Cycle
Start by writing a test that describes the next behavior you want your code to have. The test should call an interface that does not exist yet (a function you have not written, a class you have not defined). Run the test — it fails (red), which confirms that the test is actually testing something. Now write the simplest possible production code to make the test pass. Do not worry about elegance or completeness — just make the test green. Once it passes (green), step back and refactor: remove duplication, rename variables, extract helper functions, improve the design. The tests stay green throughout refactoring because you are only changing structure, not behavior. Then start the next cycle with a new failing test.
import pytest
from calculator import Calculator
# Step 1: Write a failing test (RED)
def test_addition():
calc = Calculator()
result = calc.add(2, 3)
assert result == 5
# Run: pytest -> FAILS because Calculator does not exist yet
# Step 2: Write minimal code to pass (GREEN)
class Calculator:
def add(self, a, b):
return a + b
# Run: pytest -> PASSES
Writing Testable Code
TDD naturally pushes you toward decoupled, testable code. When a test is hard to write, that is a signal that your design has problems — tight coupling, hidden dependencies, or unclear responsibilities. For example, if a function reads from a database or calls an external API, testing it directly would require setting up a real database connection or network access. Instead, inject dependencies as parameters so they can be replaced with test doubles (mocks, stubs, or fakes) during testing.
# Untestable — hard-coded dependency
def send_welcome_email(user_id):
user = database.query(f"SELECT * FROM users WHERE id = {user_id}")
smtp.send(user.email, "Welcome!", "Thanks for signing up!")
# Testable — dependency injection
def send_welcome_email(user_id, db, mailer):
user = db.get_user(user_id)
mailer.send(user.email, "Welcome!", "Thanks for signing up!")
# Now the test can pass in mocks
from unittest.mock import MagicMock
def test_send_welcome_email():
mock_db = MagicMock()
mock_db.get_user.return_value = type('User', (), {'email': 'test@example.com'})()
mock_mailer = MagicMock()
send_welcome_email(1, mock_db, mock_mailer)
mock_mailer.send.assert_called_once_with(
"test@example.com", "Welcome!", "Thanks for signing up!"
)
Testing Edge Cases
Good tests cover not just the happy path but also edge cases — empty inputs, negative numbers, boundary values, nulls, duplicates, and error conditions. Each edge case should be a separate test with a descriptive name so that when a test fails, you immediately know what scenario broke. Parametrized tests let you run the same test logic with multiple inputs without duplicating code.
# Edge case tests for a divide function
def test_divide_positive():
assert divide(10, 2) == 5
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
@pytest.mark.parametrize("a, b, expected", [
(10, 2, 5), (0, 5, 0), (-6, 3, -2), (7, 3, 7/3),
])
def test_divide_parametrized(a, b, expected):
assert divide(a, b) == expected
Test Fixtures and Setup
Fixtures handle repeated setup and teardown logic. In pytest, fixtures are functions decorated with @pytest.fixture that return objects or data needed by tests. Pytest manages fixture lifecycle — session-scoped fixtures are created once per test run, module-scoped once per module, and function-scoped (the default) for each test. Use fixtures to create test databases, load sample data, set up configuration, or instantiate complex objects.
import pytest, tempfile, os
@pytest.fixture
def calculator():
return Calculator()
@pytest.fixture
def temp_data_file():
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
f.write("name,age\nAlice,30\nBob,25\n")
path = f.name
yield path
os.unlink(path)
def test_calculator_add(calculator):
assert calculator.add(2, 3) == 5
def test_load_csv(temp_data_file):
data = load_csv(temp_data_file)
assert len(data) == 2
Mocking External Dependencies
When your code interacts with external services (APIs, databases, file systems), mocking lets you test the behavior without the real dependency. Python’s unittest.mock library provides Mock and patch for replacing objects during testing. Use patch as a context manager or decorator to temporarily replace a function or class with a mock that records how it was called and returns configured values.
from unittest.mock import patch
@patch('myapp.mailer.send')
def test_registration_sends_email(mock_send):
register_user("alice@example.com")
mock_send.assert_called_once()
# Mocking external API calls
from unittest.mock import MagicMock
@patch('requests.get')
def test_fetch_user(mock_get):
mock_response = MagicMock()
mock_response.json.return_value = {"id": 1, "name": "Alice"}
mock_response.status_code = 200
mock_get.return_value = mock_response
result = fetch_user(1)
assert result["name"] == "Alice"
TDD is a discipline that takes practice. The first few weeks feel slower because you are writing tests before code, but the speed compounds quickly — you spend far less time manually testing, debugging regressions, and fixing bugs that reach production. Teams that adopt TDD consistently report higher code quality, fewer production incidents, and greater confidence when refactoring or adding features.
