šÆ What You Will Learn
Unit tests with a mocked database give you a green checkmark and a false sense of safety. Integration tests hit a real database through your real endpoints ā and catch the bugs that actually reach production.
- Why a mocked DB hides real bugs (see it pass while prod breaks)
- Choosing a test database strategy (and why it should match prod)
- Swapping the DB with
dependency_overrides+TestClient - pytest fixtures for engine, session, and client
- Testing full CRUD flows and error paths (404, 422, 409)
- Per-test isolation so tests never leak state into each other
Prerequisites: you can write a basic unit test (fastapi-unit-testing) and you have a CRUD app talking to Postgres (fastapi-crud-patterns, fastapi-database-transactions). We'll test a small User API end-to-end.
fastapi-integration/
āāā requirements.txt
āāā docker-compose.yml # postgres for local + CI
āāā app/
ā āāā __init__.py
ā āāā database.py # get_session (the thing we override)
ā āāā models.py # User (email unique)
ā āāā schemas.py # UserCreate / UserRead
ā āāā main.py # /users endpoints
āāā tests/
āāā __init__.py
āāā conftest.py # fixtures: engine, session, client
āāā test_users.py # the integration testsWhy a Mocked Database Lies to You (See It First)
Here's a "passing" unit test. It mocks the session, so it never runs a single line of SQL ā and that's exactly the problem.
from unittest.mock import MagicMock
def test_create_user_unit():
fake_session = MagicMock() # no real database at all
# ...call the handler with the fake session...
# assert it "worked"
assert True # š¢ passes forever, even if the real SQL is brokenUNIQUE index, a wrong column type, a broken join, a migration that never ran, a NOT NULL you forgot. The mock happily returns whatever you tell it, so the test is green while a real request would 500. Mocks test your code's logic; they can't test your code against the database.| Unit test | Integration test | |
|---|---|---|
| Database | Mocked / faked | Real (a test DB) |
| Speed | Microseconds | Milliseconds |
| Catches SQL / schema bugs | ā No | ā Yes |
| Catches constraint violations | ā No | ā Yes |
| Tests the request ā response path | Partly | ā End-to-end |
Choosing a Test Database
The golden rule of integration testing: test against the database you run in production. If prod is Postgres, test on Postgres ā not SQLite. Your options:
| Option | Good for | Trade-off |
|---|---|---|
| Dedicated test DB (e.g. app_test on local/CI Postgres) | Real parity, simple, works great in CI | You manage schema setup/teardown |
| testcontainers-python (spins up Postgres in Docker per run) | Perfect isolation, zero shared state | Needs Docker available; slower cold start |
| SQLite in-memory | Blazing fast smoke tests | ā Different SQL dialect ā misses Postgres-only bugs |
JSONB, certain constraints, ARRAY). Use it only for quick logic smoke tests. For trustworthy integration tests, use Postgres.We'll use a dedicated Postgres test database ā the simplest option that gives real parity.
The Key Trick: dependency_overrides
Your endpoints get their session from a dependency (get_session). FastAPI lets tests override any dependency, so we point the app at the test database without touching a line of app code. This is the whole foundation of testing FastAPI.
from sqlmodel import Session, create_engine
from app.settings import settings
engine = create_engine(settings.database_url)
def get_session():
with Session(engine) as session:
yield session # <-- the dependency we'll override in testsfrom app.main import app
from app.database import get_session
def get_session_override():
# return the TEST session instead of the real one
return test_session
app.dependency_overrides[get_session] = get_session_override
# ... run tests ...
app.dependency_overrides.clear() # always clean upget_session, every endpoint that depends on it ā directly or through another dependency ā now uses your test session. One override, whole app redirected.pytest Fixtures: engine, session, client
Now the real setup. Three fixtures in conftest.py: a session bound to the test DB (with fresh tables per test for isolation), and a TestClient wired to use it via the override.
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from app.database import get_session
from app.main import app
TEST_DATABASE_URL = "postgresql+psycopg://app:app@localhost:5432/app_test"
@pytest.fixture(name="session")
def session_fixture():
engine = create_engine(TEST_DATABASE_URL)
SQLModel.metadata.create_all(engine) # fresh schema for this test
with Session(engine) as session:
yield session
SQLModel.metadata.drop_all(engine) # tear it all down after
engine.dispose()
@pytest.fixture(name="client")
def client_fixture(session: Session):
# the app uses OUR test session for the duration of the test
app.dependency_overrides[get_session] = lambda: session
client = TestClient(app)
yield client
app.dependency_overrides.clear()drop_all() would wipe it. Use a dedicated app_test database, and ideally guard it ā e.g. assert the URL ends with _test before creating the engine.Your First Integration Test
This test sends a real HTTP request through TestClient, which runs your endpoint, which writes to the real test database ā then reads it back. A genuine round trip.
def test_create_user(client):
response = client.post(
"/users",
json={"email": "[email protected]", "full_name": "Ada Lovelace", "password": "secret"},
)
assert response.status_code == 201
body = response.json()
assert body["email"] == "[email protected]"
assert body["full_name"] == "Ada Lovelace"
assert "id" in body
assert "password" not in body # response_model safety, verified end-to-endTesting the Full CRUD Flow
Integration tests shine when you exercise a whole flow: create, then read the thing you created, then confirm it shows up in the list. Each assertion leans on a real DB round trip.
def test_create_then_read_round_trip(client):
created = client.post(
"/users",
json={"email": "[email protected]", "full_name": "Grace Hopper", "password": "pw"},
).json()
# read it back by id ā proves it was really persisted
response = client.get(f"/users/{created['id']}")
assert response.status_code == 200
assert response.json()["id"] == created["id"]
assert response.json()["email"] == "[email protected]"
def test_list_users(client):
client.post("/users", json={"email": "[email protected]", "full_name": "A", "password": "pw"})
client.post("/users", json={"email": "[email protected]", "full_name": "B", "password": "pw"})
response = client.get("/users")
assert response.status_code == 200
assert len(response.json()) == 2 # isolation: only THIS test's rows existtest_list_users can safely assert == 2. Thanks to per-test isolation (fresh tables), no rows from other tests are hanging around. That predictability is what makes integration tests maintainable.Testing Error Paths (404, 422, 409)
The happy path is the easy half. Real confidence comes from testing what happens when things go wrong ā and these are exactly the cases mocks fumble.
def test_get_missing_user_returns_404(client):
response = client.get("/users/999999")
assert response.status_code == 404
def test_invalid_email_returns_422(client):
# Pydantic validation rejects a bad email before it hits the DB
response = client.post(
"/users",
json={"email": "not-an-email", "full_name": "X", "password": "pw"},
)
assert response.status_code == 422
def test_duplicate_email_returns_409(client):
payload = {"email": "[email protected]", "full_name": "First", "password": "pw"}
assert client.post("/users", json=payload).status_code == 201
# second insert hits the UNIQUE constraint in the REAL database
response = client.post("/users", json=payload)
assert response.status_code == 409That last test only works because the app catches the database's integrity error and turns it into a clean 409 ā the kind of code a mocked test would never exercise:
from sqlalchemy.exc import IntegrityError
@app.post("/users", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def create_user(payload: UserCreate, session: SessionDep):
user = User(
email=payload.email,
full_name=payload.full_name,
hashed_password=hash_password(payload.password),
)
session.add(user)
try:
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already exists")
session.refresh(user)
return userFaster Isolation: Transaction Rollback per Test
Dropping and recreating tables per test is correct but gets slow with hundreds of tests. The pro move: wrap each test in a transaction and roll it back at the end. Setup runs once; each test starts clean in milliseconds.
import pytest
from sqlalchemy import event
from sqlmodel import Session, SQLModel, create_engine
engine = create_engine(TEST_DATABASE_URL)
SQLModel.metadata.create_all(engine) # once for the whole run
@pytest.fixture(name="session")
def session_fixture():
connection = engine.connect()
transaction = connection.begin() # outer transaction
session = Session(bind=connection)
# restart a SAVEPOINT after each inner commit so app-level
# commit() doesn't actually persist past the test
nested = connection.begin_nested()
@event.listens_for(session, "after_transaction_end")
def restart_savepoint(sess, trans):
nonlocal nested
if not nested.is_active:
nested = connection.begin_nested()
yield session
session.close()
transaction.rollback() # undo EVERYTHING this test did
connection.close()begin_nested() + after_transaction_end trick means even endpoints that call session.commit() (like our transfer/create code) are still fully rolled back after the test. You get speed and perfect isolation. (Same begin_nested savepoints from fastapi-database-transactions, reused for testing.)create_all/drop_all version. Only reach for the rollback variant when suite speed actually hurts ā it's more moving parts, and premature optimization here just adds confusion.Run It
fastapi==0.116.1
uvicorn[standard]==0.30.6
sqlmodel==0.0.22
psycopg[binary]==3.2.1
pydantic[email]==2.9.2
pydantic-settings==2.5.2
pytest==8.3.2
httpx==0.27.2 # required by TestClient# start postgres and create the test DB
docker compose up -d
docker compose exec db createdb -U app app_test || true
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt$ pytest -v tests/test_users.py::test_create_user PASSED [ 16%] tests/test_users.py::test_create_then_read_round_trip PASSED [ 33%] tests/test_users.py::test_list_users PASSED [ 50%] tests/test_users.py::test_get_missing_user_returns_404 PASSED [ 66%] tests/test_users.py::test_invalid_email_returns_422 PASSED [ 83%] tests/test_users.py::test_duplicate_email_returns_409 PASSED [100%] ======================= 6 passed in 0.42s =======================
Common Mistakes (and Fixes)
| Mistake | Symptom | Fix |
|---|---|---|
| Testing against SQLite when prod is Postgres | Tests pass; Postgres-only bugs ship | Use a real Postgres test DB |
Forgetting httpx | TestClient import/runtime error | pip install httpx |
Not clearing dependency_overrides | Overrides leak into other tests | app.dependency_overrides.clear() in the fixture |
| No isolation between tests | Order-dependent, flaky assertions on counts | Fresh tables (or rollback) per test |
| Sharing one session for setup and the client | Data written in-test not visible to the endpoint (or vice versa) | Use the SAME session in the override and arrange steps |
| Pointing tests at the dev/prod DB | drop_all() wipes real data | Dedicated _test DB + a URL guard |
Production Notes
- Run migrations in CI, don't just
create_all. For real parity, apply your Alembic migrations against the test DB (fastapi-alembic-migrations) so you also test that migrations work ā not just the models. - Wire it into CI. GitHub Actions can spin up a Postgres service container and run
pyteston every push. The samedocker-compose.ymlworks locally and in the pipeline. - Keep the pyramid. Many fast unit tests, a solid layer of integration tests for DB-touching paths, a few end-to-end tests. Don't integration-test pure functions.
- Seed with factories. As the suite grows, helper functions or a factory library keep test data setup readable instead of copy-pasted JSON.
What's Next
fastapi-unit-testingā the fast, mocked half of the testing pyramidfastapi-database-transactionsā the savepoints powering the rollback isolation trickfastapi-crud-patternsā the endpoints these tests exercisefastapi-dockerā the compose setup that runs your test Postgres locally and in CI
Recap: mocks test your logic; integration tests test your logic against the database. Override get_session to point TestClient at a real Postgres test DB, keep each test isolated (fresh tables, or transaction rollback for speed), and cover the error paths ā 404, 422, 409 ā where the real bugs hide. That's the suite worth trusting before you ship.
fastapi-integration/ # ā
finished
āāā requirements.txt # + pytest, httpx
āāā docker-compose.yml # postgres (local + CI)
āāā app/ # the app under test (unchanged)
āāā tests/
āāā conftest.py # engine + session + client fixtures
āāā test_users.py # CRUD round trips + 404/422/409