Tutorial

Clean Code Python: Testing Strategies for the Full Stack

Unit tests, integration tests, and API tests — each has a role in a Python backend. Here is a complete testing strategy for a FastAPI + SQLAlchemy project, showing what to test at each layer and how the patterns from this series make testing effortless.

Tin Dang avatar
Tin Dang
Scientific laboratory equipment arranged precisely showing methodical verification and quality assurance

If the Repository pattern from Part 3, the Protocols from Part 2, and the Service Layer from Part 4 feel like overhead, testing is where they pay back every penny. Writing a unit test for a service that depends on a concrete SQLAlchemy session is painful. Writing one for a service that depends on a BookRepositoryProtocol takes three lines.

The architecture this series built was not designed for its own sake. It was designed so that each piece can be tested in isolation, at the right level, with the right tools.

The Three Testing Layers

Every backend test suite should have three layers, each with a distinct purpose and a different cost profile:

Unit TestsIntegration TestsAPI Tests
What is tested Business logic, domain rules SQL queries, constraints, joins HTTP contract, status codes
Dependencies Fake repositories (in-memory) Real SQLite test database Fake services via DI override
Speed Milliseconds per test Tens of milliseconds Hundreds of milliseconds
Isolation Complete — no I/O Database only Full application stack
Tools pytest + fakes pytest-asyncio + aiosqlite FastAPI TestClient
Count ratio Many (70%) Some (20%) Few (10%)

The ratio matters as much as the presence of each layer. A suite with 90% API tests is slow and brittle — every test fails when you change a route signature. A suite with 90% unit tests misses constraint violations and JOIN correctness that only real SQL can catch.

Project Setup: pytest-asyncio

Add to pyproject.toml:

[tool.pytest.ini_options]
asyncio_mode = "auto"
pythonpath = ["."]
[tool.pytest.ini_options.markers]
integration = "marks tests as integration tests (require database)"

Install the required packages:

Terminal window
pip install pytest pytest-asyncio aiosqlite httpx

Create tests/conftest.py with fixtures for each testing layer:

tests/conftest.py
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from src.db.base import Base
from tests.fakes import FakeBookRepository, FakeBookService
# ─── Unit test fixtures (no database) ────────────────────────────────────────
@pytest.fixture
def fake_book_repo() -> FakeBookRepository:
"""In-memory repository for service unit tests."""
return FakeBookRepository()
@pytest.fixture
def fake_book_service() -> FakeBookService:
"""In-memory service for API unit tests."""
return FakeBookService()
# ─── Integration test fixtures (real SQLite in-memory database) ───────────────
@pytest_asyncio.fixture
async def db_session() -> AsyncSession:
"""Real SQLAlchemy async session backed by an in-memory SQLite database.
Schema is created fresh for each test and dropped after.
Each test gets a clean slate.
"""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
SessionLocal = async_sessionmaker(engine)
async with SessionLocal() as session:
yield session
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()

The db_session fixture creates a fresh in-memory SQLite database for every test that requests it. Tests are fully isolated — no leftover data from previous runs, no test ordering dependencies.

Layer 1: Unit Tests (Services with Fakes)

The FakeBookRepository from Part 2 is the key enabler here. A fake that implements BookRepositoryProtocol using a plain Python dict lets service tests run without any database:

tests/fakes.py
from decimal import Decimal
from src.core.protocols import BookRepositoryProtocol
from src.schemas.book import BookCreate, BookResponse
from src.core.exceptions import BookNotFoundError
class FakeBookRepository:
"""In-memory implementation of BookRepositoryProtocol for testing."""
def __init__(self):
self._store: dict[int, BookResponse] = {}
self._next_id = 1
async def get_by_id(self, book_id: int) -> BookResponse | None:
return self._store.get(book_id)
async def create(self, data: BookCreate) -> BookResponse:
book = BookResponse(
id=self._next_id,
title=data.title,
isbn=data.isbn,
price=data.price,
)
self._store[self._next_id] = book
self._next_id += 1
return book
async def search(self, query: str) -> list[BookResponse]:
q = query.lower()
return [b for b in self._store.values() if q in b.title.lower()]

With the fake in place, service tests are straightforward:

tests/unit/test_book_service.py
import pytest
from decimal import Decimal
from src.services.book_service import BookService
from src.core.exceptions import BookNotFoundError, InvalidISBNError
from src.schemas.book import BookCreate
from tests.fakes import FakeBookRepository
@pytest.fixture
def service(fake_book_repo):
return BookService(repo=fake_book_repo)
async def test_get_book_raises_when_not_found(service):
with pytest.raises(BookNotFoundError) as exc_info:
await service.get_book(999)
assert exc_info.value.book_id == 999
async def test_add_book_validates_isbn(service):
with pytest.raises(InvalidISBNError):
await service.add_book(
BookCreate(title="Test Book", isbn="not-valid-isbn", price=Decimal("9.99"))
)
async def test_bulk_discount_applied_for_ten_or_more(service, fake_book_repo):
# Seed 10 books at $10 each
for i in range(10):
await fake_book_repo.create(
BookCreate(title=f"Book {i}", isbn=f"978000000{i:04d}", price=Decimal("10.00"))
)
book_ids = list(range(1, 11))
total = await service.calculate_order_total(book_ids)
assert total == Decimal("85.00") # 100 * (1 - 0.15)
async def test_bulk_discount_not_applied_below_ten(service, fake_book_repo):
for i in range(9):
await fake_book_repo.create(
BookCreate(title=f"Book {i}", isbn=f"978000000{i:04d}", price=Decimal("10.00"))
)
book_ids = list(range(1, 10))
total = await service.calculate_order_total(book_ids)
assert total == Decimal("90.00") # no discount at 9 books

Layer 2: Integration Tests (Repositories with Real DB)

Unit tests with fakes cannot catch real SQL behavior: constraint violations, JOIN correctness, pagination, index usage, or the exact behavior of ON CONFLICT. Integration tests use a real (in-memory SQLite) database to verify that the repository actually works:

tests/integration/test_book_repository.py
import pytest
from decimal import Decimal
from src.repositories.book_repository import BookRepository
from src.schemas.book import BookCreate
pytestmark = pytest.mark.integration
async def test_create_book_persists(db_session):
repo = BookRepository(db_session)
book = await repo.create(
BookCreate(title="Clean Code", isbn="9780132350884", price=Decimal("39.99"))
)
assert book.id is not None
# Verify it round-trips through the database
fetched = await repo.get_by_id(book.id)
assert fetched is not None
assert fetched.title == "Clean Code"
assert fetched.isbn == "9780132350884"
async def test_create_book_raises_on_duplicate_isbn(db_session):
repo = BookRepository(db_session)
data = BookCreate(title="Book", isbn="9780132350884", price=Decimal("10.00"))
await repo.create(data)
# Duplicate ISBN should raise a domain error, not leak a DB exception
with pytest.raises(ValueError, match="already exists"):
await repo.create(data)
async def test_search_returns_matching_books(db_session):
repo = BookRepository(db_session)
await repo.create(BookCreate(title="Python Tricks", isbn="9781775093329", price=Decimal("29.99")))
await repo.create(BookCreate(title="Fluent Python", isbn="9781491946008", price=Decimal("59.99")))
await repo.create(BookCreate(title="Clean Code", isbn="9780132350884", price=Decimal("39.99")))
python_books = await repo.search("python")
assert len(python_books) == 2
tricks_books = await repo.search("tricks")
assert len(tricks_books) == 1
assert tricks_books[0].title == "Python Tricks"
async def test_get_nonexistent_book_returns_none(db_session):
repo = BookRepository(db_session)
result = await repo.get_by_id(99999)
assert result is None

Integration tests run a real SELECT, INSERT, and constraint check. The db_session fixture creates a fresh in-memory database for each test, so tests are isolated from each other without needing transaction rollbacks or explicit cleanup.

Layer 3: API Tests (Full Stack with TestClient)

API tests verify the HTTP contract: that a request to a given route returns the expected status code and response shape. They do not need a real database — they inject a fake service using FastAPI’s dependency_overrides:

tests/api/test_books_api.py
import pytest
from fastapi.testclient import TestClient
from src.main import app
from src.api.deps import get_book_service
from tests.fakes import FakeBookService
@pytest.fixture
def client(fake_book_service):
app.dependency_overrides[get_book_service] = lambda: fake_book_service
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
def test_get_book_returns_200_when_found(client, fake_book_service):
# Seed the fake service
book = fake_book_service.add(title="Clean Code", isbn="9780132350884", price="39.99")
response = client.get(f"/api/v1/books/{book.id}")
assert response.status_code == 200
data = response.json()
assert data["title"] == "Clean Code"
assert data["isbn"] == "9780132350884"
def test_get_book_returns_404_when_missing(client):
response = client.get("/api/v1/books/999")
assert response.status_code == 404
data = response.json()
assert data["error"]["code"] == "book_not_found"
def test_create_book_returns_201_on_success(client):
response = client.post(
"/api/v1/books/",
json={"title": "New Book", "isbn": "9781234567890", "price": "29.99"},
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "New Book"
assert "id" in data
def test_create_book_returns_422_for_invalid_isbn(client):
response = client.post(
"/api/v1/books/",
json={"title": "Test", "isbn": "not-valid", "price": "9.99"},
)
assert response.status_code == 422
data = response.json()
assert data["error"]["code"] == "invalid_isbn"
assert data["error"]["field"] == "isbn"

The FakeBookService for API tests is similar to FakeBookRepository but operates at the service level — it raises the same domain exceptions the real service would raise, so the exception handlers from Part 6 work correctly in tests.

Running Tests Selectively

Terminal window
# Fast unit tests only — milliseconds
pytest tests/unit/ -v
# Integration tests only — requires SQLite in-memory database
pytest tests/integration/ -v -m integration
# API tests only
pytest tests/api/ -v
# Full test suite
pytest tests/ -v
# Coverage report with missing lines shown
pytest tests/ --cov=src --cov-report=term-missing
# Exclude slow integration tests for a fast local loop
pytest tests/ -v -m "not integration"

The -m integration marker (configured in pyproject.toml) lets you exclude integration tests from your fast local loop. Run pytest tests/unit/ constantly during development, pytest tests/ before committing.

The Series in Review

Every part of this series built toward this testing picture:

Part 1: Project structure → clean layer boundaries, one concern per file
Part 2: Protocols → type-safe duck typing, decoupled interfaces
Part 3: Repository → database access isolated, testable with fakes
Part 4: Service Layer → business logic centralized, domain exceptions
Part 5: Dependency Injection → Depends() wires it all together per-request
Part 6: Error Handling → consistent HTTP responses from domain exceptions
Part 7: Async Patterns → concurrent I/O, eager loading, background tasks
Part 8: Testing → each layer tested at the right level
  • Part 2 ProtocolsFakeBookRepository implements the same Protocol as the real repository. The service does not know which one it is using.
  • Part 3 Repository → All database access goes through the repository. Unit tests replace the repository with a fake and never touch the DB.
  • Part 4 Service Layer → Business logic lives in services, not route handlers. One service test covers a rule that would otherwise require API-layer testing.
  • Part 5 DIdependency_overrides is built into FastAPI precisely for this pattern. No monkey-patching, no mock frameworks.
  • Part 6 Error Handling → Exception handlers are registered application-wide. API tests get the same error shape as production without extra setup.

Key Takeaways

  • Unit tests for business logic with fakes. Services tested against fake repositories run in milliseconds and catch all domain rule violations.
  • Integration tests for SQL behavior. Constraint violations, JOIN correctness, and pagination only surface with a real database. Use in-memory SQLite for speed.
  • API tests for HTTP contract verification. Status codes, response shapes, and error codes verified via TestClient with injected fake services.
  • asyncio_mode = "auto" in pytest.ini. Eliminates @pytest.mark.asyncio boilerplate from every test function.
  • dependency_overrides for fast API tests. No mocking frameworks. FastAPI’s built-in override mechanism replaces the production dependency with a fake for the duration of the test.

This series built a single codebase from an empty directory to a production-ready FastAPI backend with eight patterns working in concert. The structure is not overhead — it is the thing that makes the tests easy and the late-night incidents rare. The Repository decouples storage from logic. The Protocol decouples the interface from the implementation. The Service Layer gives business rules a home. Dependency injection wires it all without coupling. Structured error handling gives clients a contract. Async patterns make I/O efficient. And testing — proper, layered testing — is how you know it all works together.

0

Next in this series

Clean Code Python: Multi-Tenant Foundation — Context, Isolation, and the Data Boundary

Continue reading