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 Tests | Integration Tests | API 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:
pip install pytest pytest-asyncio aiosqlite httpxCreate tests/conftest.py with fixtures for each testing layer:
import pytestimport pytest_asynciofrom sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSessionfrom src.db.base import Basefrom tests.fakes import FakeBookRepository, FakeBookService
# ─── Unit test fixtures (no database) ────────────────────────────────────────
@pytest.fixturedef fake_book_repo() -> FakeBookRepository: """In-memory repository for service unit tests.""" return FakeBookRepository()
@pytest.fixturedef fake_book_service() -> FakeBookService: """In-memory service for API unit tests.""" return FakeBookService()
# ─── Integration test fixtures (real SQLite in-memory database) ───────────────
@pytest_asyncio.fixtureasync 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:
from decimal import Decimalfrom src.core.protocols import BookRepositoryProtocolfrom src.schemas.book import BookCreate, BookResponsefrom 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:
import pytestfrom decimal import Decimalfrom src.services.book_service import BookServicefrom src.core.exceptions import BookNotFoundError, InvalidISBNErrorfrom src.schemas.book import BookCreatefrom tests.fakes import FakeBookRepository
@pytest.fixturedef 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 booksLayer 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:
import pytestfrom decimal import Decimalfrom src.repositories.book_repository import BookRepositoryfrom 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 NoneIntegration 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:
import pytestfrom fastapi.testclient import TestClientfrom src.main import appfrom src.api.deps import get_book_servicefrom tests.fakes import FakeBookService
@pytest.fixturedef 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
# Fast unit tests only — millisecondspytest tests/unit/ -v
# Integration tests only — requires SQLite in-memory databasepytest tests/integration/ -v -m integration
# API tests onlypytest tests/api/ -v
# Full test suitepytest tests/ -v
# Coverage report with missing lines shownpytest tests/ --cov=src --cov-report=term-missing
# Exclude slow integration tests for a fast local looppytest 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 filePart 2: Protocols → type-safe duck typing, decoupled interfacesPart 3: Repository → database access isolated, testable with fakesPart 4: Service Layer → business logic centralized, domain exceptionsPart 5: Dependency Injection → Depends() wires it all together per-requestPart 6: Error Handling → consistent HTTP responses from domain exceptionsPart 7: Async Patterns → concurrent I/O, eager loading, background tasksPart 8: Testing → each layer tested at the right level- Part 2 Protocols →
FakeBookRepositoryimplements 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 DI →
dependency_overridesis 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
TestClientwith injected fake services. asyncio_mode = "auto"in pytest.ini. Eliminates@pytest.mark.asyncioboilerplate from every test function.dependency_overridesfor 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.