Week 8: Testing FastAPI Applications

By now the task API from Weeks 2–7 has routing, validation, auth and a real Postgres-backed persistence layer — which means it also has plenty of ways to quietly break the next time someone touches it. This week is about building confidence instead of hoping: writing fast pytest unit tests around pure business logic, driving the actual HTTP layer with FastAPI's TestClient while swapping the database out from under it with dependency overrides, and running a slower integration suite against a real, disposable PostgreSQL container so the fast tests aren't your only line of defense.

Module 6 of 22 Week 8 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Structure pytest fixtures so unit tests exercise business logic without touching FastAPI or the database
  • Drive real HTTP requests through TestClient while swapping dependencies with app.dependency_overrides
  • Run integration tests against a real, disposable PostgreSQL container with Testcontainers, migrated with Alembic

1. pytest Fixtures & Unit-Test Boundaries

A pytest fixture is a function decorated with @pytest.fixture that provides setup (and, via yield, teardown) for a test. Instead of every test hand-rolling its own database connection or fake object, tests declare a fixture as a parameter and pytest resolves and injects it automatically. The teardown code — everything after yield — always runs after the test finishes, even if the test raises an assertion error, which is what makes fixtures reliable for cleanup.

Fixture scope controls how often the setup/teardown code actually runs. The default, function scope, gives every test a fresh instance — safest, but slowest if the setup is expensive. session scope runs the setup once for the entire test run and reuses it everywhere, which is exactly what you want for something like a database engine (expensive to create, safe to share) but wrong for something that needs to reset between tests (like an open transaction).

tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session

@pytest.fixture(scope="session")
def engine():
    # created once for the whole test run -- connecting is the expensive part
    return create_engine("sqlite:///:memory:")

@pytest.fixture
def db_session(engine):
    # fresh per test: open a connection, start a transaction, hand out a session
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    yield session
    session.close()
    transaction.rollback()   # undoes everything the test wrote
    connection.close()

conftest.py is special: pytest auto-discovers it in every directory on the way down to a test file and makes its fixtures available without an import. Centralizing engine, db_session, and (as you'll see next) client in one conftest.py at the root of tests/ means every test module in the project shares the same setup without repeating it.

The real payoff of fixtures is the unit-test boundary they let you draw: business logic that doesn't need FastAPI or a database at all should be tested directly, as plain functions, with zero fixtures beyond the inputs you're passing in. Reserve db_session and client for tests that specifically need to prove the wiring works.

app/domain.py + tests/test_domain.py
# app/domain.py -- no FastAPI import, no database import
def normalize_task_title(title: str) -> str:
    cleaned = title.strip()
    if not cleaned:
        raise ValueError("title must not be blank")
    return cleaned[:200]


# tests/test_domain.py -- no fixtures needed, runs in microseconds
from app.domain import normalize_task_title
import pytest

def test_normalize_task_title_strips_whitespace():
    assert normalize_task_title("  Ship it  ") == "Ship it"

def test_normalize_task_title_rejects_blank():
    with pytest.raises(ValueError):
        normalize_task_title("   ")
Why this matters

A test suite made entirely of full HTTP-request tests is slow enough that people stop running it locally. Pushing every rule that doesn't strictly need FastAPI or the database down into plain functions like normalize_task_title means you can have hundreds of unit tests that finish in well under a second, and reserve the slower TestClient and Testcontainers layers for what actually needs them.

2. TestClient & Dependency Overrides

FastAPI's TestClient (built on httpx) sends requests straight into your ASGI app in-process — no real socket, no running server — while still exercising the entire request pipeline: routing, Pydantic validation, your dependency graph, and the route function itself. That's exactly the boundary a unit test skips, and exactly what you want confidence in.

The problem is the database. Your routes call get_session (from Week 4) via Depends(get_session) to get a real SQLAlchemy session bound to your actual Postgres database — not something you want touching production data, or even a real database at all, for a fast test run. app.dependency_overrides is a plain dictionary FastAPI checks before resolving any Depends() call: map the original callable to a replacement, and every route that depends on it gets the replacement instead, with zero changes to route code.

tests/conftest.py (continued)
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.db import get_session

@pytest.fixture
def test_session(db_session):
    def override_session():
        yield db_session

    app.dependency_overrides[get_session] = override_session
    yield db_session
    app.dependency_overrides.clear()   # don't leak into the next test

@pytest.fixture
def client(test_session):
    return TestClient(app)
tests/test_tasks_api.py
def test_create_task(client):
    response = client.post("/tasks", json={"title": "Ship it"})
    assert response.status_code == 201
    assert response.json()["title"] == "Ship it"

This beats mocking SQLAlchemy directly because a mock only proves your code calls the ORM the way you expect — it can't catch a malformed query, a wrong column name, or a constraint your route forgot to handle, because a mock has no schema and no real query engine underneath it. Overriding get_session instead swaps out exactly one thing — which database connection the app talks to — and lets every other layer, including the real SQLAlchemy query building and the real Pydantic validation, run for real. The db_session fixture's rolled-back transaction from Section 1 is what keeps each test's writes from leaking into the next one.

3. Real-Database Integration Tests with Testcontainers

The unit and TestClient layers above typically run against SQLite or an in-memory engine for speed, which is fine for exercising your application code but blind to anything Postgres-specific: JSON column behavior, actual constraint enforcement, case-sensitivity quirks, or a migration that works on SQLite but fails against real Postgres. Testcontainers closes that gap by launching an actual, disposable PostgreSQL instance in Docker for the test run, then throwing it away.

tests/conftest.py (integration fixtures)
import pytest
from testcontainers.postgres import PostgresContainer
from alembic.config import Config
from alembic import command

@pytest.fixture(scope="session")
def postgres_container():
    with PostgresContainer("postgres:16-alpine") as postgres:
        yield postgres

@pytest.fixture(scope="session")
def migrated_database_url(postgres_container):
    url = postgres_container.get_connection_url()
    alembic_cfg = Config("alembic.ini")
    alembic_cfg.set_main_option("sqlalchemy.url", url)
    command.upgrade(alembic_cfg, "head")   # run every migration from Week 5
    return url

Both fixtures are session-scoped: starting a container and running every migration takes real time (seconds, not milliseconds), so it happens once per test run rather than once per test. Individual integration tests then open their own connection against migrated_database_url and are responsible for their own cleanup — usually a transaction rollback per test, the same pattern as db_session above, just pointed at a real engine instead of SQLite.

This is a genuinely different test layer, not a slower version of the same one: unit tests prove your logic is correct in isolation; TestClient tests prove the HTTP-to-application wiring works; Testcontainers integration tests prove your actual SQL and actual migrations behave correctly against the actual database engine you run in production. Because they're slow and need a Docker daemon, mark them separately so they can be skipped in a fast local loop:

terminal
# pytest.ini or pyproject.toml [tool.pytest.ini_options]
# markers =
#     integration: slow tests that need Docker

pytest -m "not integration"   # fast loop: unit + TestClient tests only
pytest -m integration         # full integration suite, needs Docker running
Why this matters

A migration that runs cleanly on SQLite can still fail against Postgres — different type systems, different default constraint behavior, different handling of things like JSON columns. Testcontainers is what turns "the tests passed" into "the tests passed against the same database engine that runs in production," which is the only version of that sentence worth trusting before a deploy.

4. Hands-on Exercise

Hands-on

Build a full test suite for the task API

Cover the task API from Weeks 2–7 with a fast unit/HTTP layer and one real-database integration test, and keep the two clearly separated.

Requirements:

  1. Write TestClient tests covering: a successful task creation (201), a validation failure for a missing or blank title (422), an authentication failure for a missing or invalid token (401/403), and a not-found response for an unknown task id (404).
  2. Use app.dependency_overrides to swap get_session for a fixture-backed session wrapped in a rolled-back transaction, so every test starts from a clean slate and none of them leak state into the next.
  3. Add a conftest.py exposing engine (session-scoped), db_session and test_session (function-scoped), and client, so the expensive setup only happens once per run.
  4. Add a session-scoped postgres_container fixture using Testcontainers and run your Week 5 Alembic migrations against it before any integration test runs.
  5. Write one integration test that creates a task through your repository layer directly against that real Postgres container and asserts it round-trips correctly, including any Postgres-specific column types you're using.
  6. Register an integration pytest marker and confirm pytest -m "not integration" runs only the fast suite while pytest -m integration runs everything.
Hint

Testcontainers needs a running Docker daemon. If it isn't available in your environment, skip the integration test cleanly with pytest.mark.skipif rather than letting the whole run fail — the fast suite in requirements 1–3 should always be runnable on its own.

5. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why override the session dependency instead of mocking SQLAlchemy everywhere?

The HTTP and persistence layers still run together for real — routing, Pydantic validation, and the actual SQLAlchemy query building all execute exactly as they do in production. The test only controls one thing at one clean boundary: which database connection get_session hands back. Mocking SQLAlchemy directly would require reimplementing its API in your mocks and could pass even if your real queries were broken, since a mock has no schema to violate.

Q2

Why put the database engine fixture at session scope but the db_session fixture at function scope?

Creating the engine (establishing the underlying connection machinery) is the expensive part and is completely safe to reuse across every test in the run, since the engine itself holds no per-test state. The session, on the other hand, wraps a transaction that gets rolled back at the end of each test to undo whatever that test wrote — reusing it across tests would let one test's data bleed into the next, so it has to be recreated per test.

Q3

Why can a Testcontainers-backed PostgreSQL integration test catch bugs that a SQLite-backed unit test suite misses entirely?

SQLite and Postgres differ in type systems, constraint enforcement, and handling of things like JSON columns or case sensitivity, so code and migrations that work fine against SQLite can fail — or silently behave differently — against real Postgres. A Testcontainers test runs your actual migrations and actual queries against the same database engine used in production, which is the only way to be confident those Postgres-specific behaviors actually work.

Q4

Why does the test_session fixture call app.dependency_overrides.clear() after yield, and what would happen if it didn't?

app.dependency_overrides lives on the single, shared app instance imported by every test module — it isn't automatically reset between tests. Clearing it after each test's yield guarantees the next test starts from a clean state; without it, an override set by one test (or one test file) could silently leak into a later test that expected the real dependency, or into a test that sets up a different override and gets confusing, hard-to-diagnose failures.