Complete guide to practicing Test-Driven Development from project setup through delivery.
Complete guide to practicing Test-Driven Development from project setup through delivery. Follow this workflow for consistent, high-quality TDD practice.
# 1. Pull latest changes
git pull origin main
# 2. Run full test suite to verify clean slate
npm test # or pytest, cargo test, go test, etc.
# 3. Review your task board
bd ready --json --limit 5 # If using Beads
# 4. Pick ONE small task to start
# Remember: Small steps, tight feedback loops
The Core Cycle (repeat every 5-10 minutes):
┌─────────────────┐
│ 1. RED │ Write failing test (30 sec - 2 min)
│ Write Test │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 2. GREEN │ Make test pass (1 - 5 min)
│ Implement │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 3. REFACTOR │ Improve design (1 - 5 min)
│ Clean Code │
└────────┬────────┘
│
▼
(Commit)
│
└──────> Next Test
# 1. Ensure all tests pass
npm test
# 2. Commit any work in progress
git add .
git commit -m "WIP: Feature X progress"
# 3. Push to remote (if on feature branch)
git push origin feature/your-feature
# 4. Update task tracking
bd export -o .beads/issues.jsonl
# 5. Quick reflection
# - How many cycles completed?
# - What went well?
# - What to improve tomorrow?
1. Choose Testing Framework
# Python
pip install pytest pytest-cov pytest-watch
# or with uv:
uv add --dev pytest pytest-cov pytest-watch
# TypeScript/JavaScript
npm install --save-dev jest @types/jest ts-jest
# or
npm install --save-dev vitest
# Rust
# Already included in cargo
# Go
# Already included in go toolchain
2. Configure Test Runner
Python (pyproject.toml):
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --cov=src --cov-report=html --cov-report=term"
TypeScript (jest.config.js):
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
collectCoverageFrom: ['src/**/*.ts'],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70
}
}
};
3. Set Up Watch Mode
# Python
pytest-watch
# JavaScript/TypeScript
npm test -- --watch
# Rust
cargo watch -x test
# Go
# Use tools like gotest or create a watch script
4. Configure IDE for TDD
VS Code (settings.json):
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": true
},
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"jest.autoRun": "watch"
}
5. Create Project Structure
project/
├── src/ # Production code
│ ├── module1/
│ └── module2/
├── tests/ # Test code
│ ├── test_module1.py
│ └── test_module2.py
├── README.md
├── pyproject.toml # or package.json, Cargo.toml, etc.
└── .gitignore
Example: Adding user authentication to an API
Feature: User Authentication
Requirements:
- User can register with email and password
- User can login with credentials
- Password must be hashed
- Invalid credentials return error
- Login returns JWT token
Break down into small tests:
1. ✓ User can be created with email
2. ✓ Password is hashed on creation
3. ✓ User can authenticate with valid credentials
4. ✓ Authentication fails with invalid password
5. ✓ Authentication returns JWT token
RED - Write failing test:
# tests/test_user.py
def test_user_can_be_created_with_email():
"""Given an email, when creating user, then user has that email"""
# Arrange
email = "[email protected]"
# Act
user = User.create(email=email, password="secret123")
# Assert
assert user.email == email
Run test (should fail - User doesn't exist):
pytest tests/test_user.py::test_user_can_be_created_with_email
GREEN - Minimal implementation:
# src/user.py
class User:
def __init__(self, email: str, password: str):
self.email = email
self.password = password
@classmethod
def create(cls, email: str, password: str):
return cls(email, password)
Run test (should pass):
pytest tests/test_user.py::test_user_can_be_created_with_email
REFACTOR (if needed):
COMMIT:
git add .
git commit -m "feat: Add User.create method with email"
RED:
def test_password_is_hashed_on_creation():
"""Given a password, when creating user, then password is hashed"""
# Arrange
plain_password = "secret123"
# Act
user = User.create(email="[email protected]", password=plain_password)
# Assert
assert user.password != plain_password
assert user.password.startswith("$2b$") # bcrypt hash prefix
GREEN:
import bcrypt
class User:
def __init__(self, email: str, password_hash: str):
self.email = email
self.password = password_hash
@classmethod
def create(cls, email: str, password: str):
password_hash = bcrypt.hashpw(
password.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
return cls(email, password_hash)
REFACTOR:
class User:
def __init__(self, email: str, password_hash: str):
self.email = email
self._password_hash = password_hash
@classmethod
def create(cls, email: str, password: str):
password_hash = cls._hash_password(password)
return cls(email, password_hash)
@staticmethod
def _hash_password(password: str) -> str:
"""Hash a password using bcrypt"""
return bcrypt.hashpw(
password.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
COMMIT:
git add .
git commit -m "feat: Hash user passwords with bcrypt"
Follow the same pattern for each requirement:
DON'T:
DO:
1. Understand the Failure
# Run with verbose output
pytest -v tests/test_user.py::test_authentication
# Look at the assertion error
# Expected: True
# Actual: False
2. Verify Test is Correct
Ask yourself:
3. Isolate the Problem
# Add a debugging test
def test_debug_authentication():
user = User.create("[email protected]", "password123")
print(f"User password hash: {user._password_hash}")
result = user.authenticate("password123")
print(f"Authentication result: {result}")
# Manually verify each step
assert user._password_hash is not None
assert result is True
4. Use Debugger
# Add breakpoint
import pdb; pdb.set_trace()
# Or use IDE debugger
# Set breakpoint and run in debug mode
5. Fix Smallest Thing
Make the minimal change to fix the issue.
6. Verify Fix
# Run the specific test
pytest tests/test_user.py::test_authentication
# Run all tests to ensure no regression
pytest
7. Clean Up
git add .
git commit -m "fix: Correct password verification in authentication"
Prerequisites:
Process:
1. Ensure Green State
# All tests MUST pass before refactoring
pytest
# ✓ All tests passed
2. Identify Refactoring Opportunity
Common triggers:
3. Make Small Change
# Before: Duplication
def calculate_discount_for_premium(price):
if price > 100:
return price * 0.8
return price
def calculate_discount_for_gold(price):
if price > 100:
return price * 0.9
return price
# After: Extract common pattern
DISCOUNT_RATES = {
'premium': 0.20,
'gold': 0.10,
}
def calculate_discount(price, customer_type):
if price <= 100:
return price
rate = DISCOUNT_RATES.get(customer_type, 0.0)
return price * (1 - rate)
4. Run Tests Immediately
pytest
5. If Red, Undo and Try Smaller Step
git checkout -- .
# Try a smaller refactoring
6. If Green, Continue or Commit
git add .
git commit -m "refactor: Extract discount calculation into common function"
7. Repeat
Continue refactoring in small steps, testing after each change.
For Reviewers:
Tests:
Production Code:
Coverage:
Design:
Process:
Questions to Ask:
1. Create feature branch
git checkout -b feature/user-search
2. Write first test
# Start with simplest case
def test_search_returns_empty_for_no_matches():
results = search_users("nonexistent")
assert results == []
3. Run test (should fail)
pytest tests/test_search.py
4. Implement minimal code
def search_users(query):
return []
5. Run test (should pass)
pytest tests/test_search.py
6. Commit
git commit -m "feat: Add basic user search (empty results)"
7. Next test
def test_search_finds_user_by_name():
...
1. Write a test that reproduces the bug
def test_handle_null_input():
# This currently fails
result = process(None)
assert result is not None
2. Verify test fails
pytest tests/test_process.py::test_handle_null_input
# Should fail, reproducing the bug
3. Fix the bug
def process(input):
if input is None:
return default_value()
# ... rest of code
4. Verify test passes
pytest tests/test_process.py::test_handle_null_input
5. Run all tests
pytest
6. Commit
git commit -m "fix: Handle null input in process()"
1. Test invalid input
def test_rejects_invalid_email():
with pytest.raises(ValueError):
create_user(email="invalid")
2. Implement validation
def create_user(email):
if not is_valid_email(email):
raise ValueError(f"Invalid email: {email}")
# ...
3. Test edge cases
@pytest.mark.parametrize("email", [
"",
"no-at-sign",
"@no-local",
"no-domain@",
"spaces [email protected]",
])
def test_rejects_malformed_emails(email):
with pytest.raises(ValueError):
create_user(email=email)
1. Add characterization tests
# Document current behavior (even if wrong)
def test_current_behavior():
result = legacy_function(input)
assert result == <whatever it currently returns>
2. Build comprehensive test suite
# Test all code paths
# Use code coverage to find gaps
3. Refactor incrementally
# Small changes
# Run tests after each change
# Keep tests passing
4. Fix bugs with new tests
# Now that you have safety net
# Write test for correct behavior
# Fix implementation
1. Create abstraction
class EmailService:
def send(self, to, subject, body):
raise NotImplementedError
2. Test with fake
class FakeEmailService(EmailService):
def __init__(self):
self.sent_emails = []
def send(self, to, subject, body):
self.sent_emails.append((to, subject, body))
def test_sends_welcome_email():
email_service = FakeEmailService()
user_service = UserService(email_service)
user_service.register("[email protected]")
assert len(email_service.sent_emails) == 1
assert email_service.sent_emails[0][0] == "[email protected]"
3. Implement real service
class SMTPEmailService(EmailService):
def send(self, to, subject, body):
# Real SMTP implementation
pass
"The code you write without a test is legacy code from the moment you write it" - Michael Feathers
"TDD is not about testing, it's about design" - Sandi Metz
"Make it work, make it right, make it fast" - Kent Beck