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).
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 -- 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(" ")
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.
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)
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.
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:
# 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
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
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:
- Write
TestClienttests 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). - Use
app.dependency_overridesto swapget_sessionfor 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. - Add a
conftest.pyexposingengine(session-scoped),db_sessionandtest_session(function-scoped), andclient, so the expensive setup only happens once per run. - Add a session-scoped
postgres_containerfixture using Testcontainers and run your Week 5 Alembic migrations against it before any integration test runs. - 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.
- Register an
integrationpytest marker and confirmpytest -m "not integration"runs only the fast suite whilepytest -m integrationruns everything.
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?
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?
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?
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?
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.