TDD for APIs and Backend Services: Practical Test-Driven Development
Backend TDD has a straightforward feedback loop: write a test for an endpoint or function, watch it fail, implement the minimum code to pass it. But the specifics matter — how you structure tests, what you mock, and where you draw boundaries between unit and integration tests.
The Backend TDD Stack
For most backend services, you need:
- Unit tests for business logic (pure functions, domain objects)
- Integration tests for database interactions
- API tests for endpoints (request in, response out)
The ratio varies by service, but a typical backend is heavy on unit tests, with integration tests for the data layer and API tests for the most important endpoints.
TDD for Business Logic
Start with the purest layer — functions that take data in and return data out.
# test_pricing.py — written before pricing.py exists
def test_base_price_with_no_discount():
assert calculate_price(base=100.00, discount_code=None) == 100.00
def test_percentage_discount():
assert calculate_price(base=100.00, discount_code='SAVE10') == 90.00
def test_fixed_discount():
assert calculate_price(base=100.00, discount_code='FLAT20') == 80.00
def test_discount_cannot_exceed_price():
assert calculate_price(base=15.00, discount_code='FLAT20') == 0.00
def test_unknown_discount_code_raises():
with pytest.raises(InvalidDiscountCode):
calculate_price(base=100.00, discount_code='FAKE99')Implement to make tests pass:
# pricing.py
DISCOUNTS = {
'SAVE10': {'type': 'percent', 'value': 10},
'FLAT20': {'type': 'fixed', 'value': 20},
}
class InvalidDiscountCode(Exception):
pass
def calculate_price(base: float, discount_code: str | None) -> float:
if discount_code is None:
return base
if discount_code not in DISCOUNTS:
raise InvalidDiscountCode(f"Unknown discount code: {discount_code}")
discount = DISCOUNTS[discount_code]
if discount['type'] == 'percent':
return base * (1 - discount['value'] / 100)
elif discount['type'] == 'fixed':
return max(0, base - discount['value'])All tests pass. The business logic is captured and protected.
TDD for Database Layer
Use a real test database — not mocks. Mocking the ORM or database client creates tests that pass but don't verify actual SQL, index behavior, or constraint enforcement.
# test_order_repository.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
@pytest.fixture(scope='session')
def engine():
return create_engine('postgresql://localhost/test_db')
@pytest.fixture(autouse=True)
def db_session(engine):
with Session(engine) as session:
with session.begin():
yield session
session.rollback()
def test_saves_order(db_session):
repo = OrderRepository(db_session)
order = repo.create(customer_id='abc', total=99.99, status='pending')
assert order.id is not None
assert order.created_at is not None
found = repo.find_by_id(order.id)
assert found.customer_id == 'abc'
assert found.total == 99.99
def test_finds_orders_by_customer(db_session):
repo = OrderRepository(db_session)
repo.create(customer_id='alice', total=50.00, status='pending')
repo.create(customer_id='alice', total=75.00, status='completed')
repo.create(customer_id='bob', total=30.00, status='pending')
alice_orders = repo.find_by_customer('alice')
assert len(alice_orders) == 2
assert all(o.customer_id == 'alice' for o in alice_orders)
def test_unique_constraint_on_order_reference(db_session):
repo = OrderRepository(db_session)
repo.create(customer_id='abc', reference='REF-001', total=10.00)
with pytest.raises(IntegrityError):
repo.create(customer_id='def', reference='REF-001', total=20.00)Implement the repository, run migrations, make tests pass. This approach validates that your schema and queries actually work together.
TDD for REST Endpoints
Test the full HTTP contract: request format, response format, status codes, headers.
# test_orders_api.py — using FastAPI's TestClient
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_create_order_returns_201():
response = client.post('/orders', json={
'customerId': 'abc',
'items': [{'productId': '123', 'quantity': 2}]
})
assert response.status_code == 201
body = response.json()
assert 'id' in body
assert body['status'] == 'pending'
assert body['customerId'] == 'abc'
def test_create_order_requires_items():
response = client.post('/orders', json={'customerId': 'abc'})
assert response.status_code == 422
errors = response.json()['detail']
assert any(e['loc'] == ['body', 'items'] for e in errors)
def test_get_order_returns_404_for_unknown():
response = client.get('/orders/nonexistent-id')
assert response.status_code == 404
assert response.json()['error'] == 'order_not_found'
def test_get_order_returns_order():
# Create first
create_response = client.post('/orders', json={
'customerId': 'abc',
'items': [{'productId': '123', 'quantity': 1}]
})
order_id = create_response.json()['id']
# Then retrieve
get_response = client.get(f'/orders/{order_id}')
assert get_response.status_code == 200
assert get_response.json()['id'] == order_idImplement the endpoint to pass these tests:
# orders.py
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
router = APIRouter()
class CreateOrderRequest(BaseModel):
customerId: str
items: list[OrderItem] # required — Pydantic validates this
@router.post('/orders', status_code=status.HTTP_201_CREATED)
def create_order(request: CreateOrderRequest, db: Session = Depends(get_db)):
order = order_service.create(
customer_id=request.customerId,
items=request.items,
db=db
)
return OrderResponse.from_orm(order)
@router.get('/orders/{order_id}')
def get_order(order_id: str, db: Session = Depends(get_db)):
order = order_repo.find_by_id(order_id, db)
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={'error': 'order_not_found'}
)
return OrderResponse.from_orm(order)TDD for Authentication and Authorization
Auth is a critical layer — TDD it thoroughly:
def test_unauthenticated_request_returns_401():
response = client.get('/orders')
assert response.status_code == 401
def test_valid_token_allows_access():
token = generate_test_token(user_id='user-123')
response = client.get('/orders', headers={'Authorization': f'Bearer {token}'})
assert response.status_code == 200
def test_expired_token_returns_401():
token = generate_test_token(user_id='user-123', expires_in_seconds=-1)
response = client.get('/orders', headers={'Authorization': f'Bearer {token}'})
assert response.status_code == 401
assert response.json()['error'] == 'token_expired'
def test_user_cannot_access_other_users_orders():
# Create order as user-1
token1 = generate_test_token(user_id='user-1')
create_response = client.post('/orders',
json={'customerId': 'user-1', 'items': [...]},
headers={'Authorization': f'Bearer {token1}'}
)
order_id = create_response.json()['id']
# Try to access as user-2
token2 = generate_test_token(user_id='user-2')
get_response = client.get(f'/orders/{order_id}',
headers={'Authorization': f'Bearer {token2}'}
)
assert get_response.status_code == 403Handling External Services in Backend TDD
When your service calls external APIs (payment processors, email services, etc.), mock at the HTTP boundary using libraries like responses (Python) or nock (Node.js):
import responses
@responses.activate
def test_payment_processing():
responses.add(
responses.POST,
'https://api.stripe.com/v1/charges',
json={'id': 'ch_test', 'status': 'succeeded', 'amount': 999},
status=200
)
result = payment_service.charge(amount=999, customer_id='cus_abc')
assert result['status'] == 'succeeded'
assert len(responses.calls) == 1
assert responses.calls[0].request.body == 'amount=999&customer=cus_abc¤cy=usd'For services where the interaction is complex, use recorded fixtures:
# Record real HTTP interaction
pytest tests/test_payments.py --record-mode=new_episodes
<span class="hljs-comment"># Replay in CI (no network access)
pytest tests/test_payments.py --record-mode=noneStructuring Backend Tests for TDD
tests/
├── unit/ # Pure logic, no I/O
│ ├── test_pricing.py
│ ├── test_validation.py
│ └── test_order_state_machine.py
├── integration/ # Real database, mocked external services
│ ├── test_order_repository.py
│ └── test_user_repository.py
└── api/ # Full HTTP request-response
├── test_orders_api.py
└── test_auth_api.pyRun unit tests on every file save. Run integration and API tests before pushing. This keeps the feedback loop fast where it matters most.
Backend TDD works best when you TDD the business logic first (fast, pure, no I/O), then drive the data layer with integration tests, then wire it all together with API tests. Each layer you add has tests verifying the layer below it — changes that break anything surface immediately.