Test Fixtures & Overrides — Real Tests, Fake Infrastructure
Intermediatedependency_overrides swaps get_db for a per-test SQLite/test-Postgres session and get_current_user for canned users — pytest fixtures wire it so every test starts on a clean, isolated world.
Overview
Real endpoints touch databases and auth; tests must control both without touching production code. Because everything flows through Depends, app.dependency_overrides can replace any node: get_db yields sessions bound to a test database (in-memory SQLite for speed, or a Dockerized Postgres for fidelity — the honest trade-off being that SQLite lacks Postgres types and constraint behaviours), and get_current_user returns canned users so tests choose their identity per test instead of forging JWTs. pytest fixtures orchestrate it: a session-scoped engine creates the schema once, a function-scoped session rolls back after each test so no test sees another's data, and role fixtures (as_student, as_tpo) make authorization tests read like the permission matrix they verify. The payoff is the testing pyramid working as designed — fast, isolated, deterministic API tests that CI runs on every push.
The conftest.py — Test DB + Rollback Isolation
Engine once per run, schema once, and each test inside a transaction that rolls back — clean state without re-creating tables 500 times. The override points get_db at the test session.
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.core.db import Base, get_db
# SQLite in-memory: fast, zero setup. (Postgres-in-Docker when you rely on
# JSONB/arrays/constraint semantics — see notes below.)
engine = create_engine("sqlite://",
connect_args={"check_same_thread": False})
TestSession = sessionmaker(bind=engine)
@pytest.fixture(scope="session", autouse=True)
def create_schema():
Base.metadata.create_all(engine) # once per test run
yield
Base.metadata.drop_all(engine)
@pytest.fixture
def db():
"""Each test runs inside a transaction that is rolled back."""
connection = engine.connect()
trans = connection.begin()
session = TestSession(bind=connection)
yield session
session.close()
trans.rollback() # ← test's writes vanish
connection.close()
@pytest.fixture
def client(db):
app.dependency_overrides[get_db] = lambda: (yield db)
with TestClient(app) as c: # 'with' runs lifespan too
yield c
app.dependency_overrides.clear() # NEVER leak overrides across tests
# tests/test_students_db.py — tests are now clean and oblivious:
def test_create_then_fetch(client):
created = client.post("/students", json={
"name": "Asha", "email": "asha@nitk.edu.in",
"branch": "CS", "cgpa": 8.7}).json()
fetched = client.get(f"/students/{created['id']}")
assert fetched.json()["name"] == "Asha"
def test_previous_test_left_nothing_behind(client):
assert client.get("/students").json()["total"] == 0 # rollback proofFake Identities + the SQLite/Postgres Trade-off
Role fixtures make authorization tests declarative. And know what in-memory SQLite does NOT test — choose Postgres-in-Docker when those behaviours are the point.
# ── identity fixtures: pick who you are per test ──
from app.core.security import get_current_user
@pytest.fixture
def as_student(client):
app.dependency_overrides[get_current_user] = lambda: {
"email": "asha@nitk.edu.in", "role": "student"}
yield client
@pytest.fixture
def as_tpo(client):
app.dependency_overrides[get_current_user] = lambda: {
"email": "rane@college.edu", "role": "tpo"}
yield client
def test_student_cannot_create_drive(as_student):
assert as_student.post("/drives", json={"company": "TCS"}).status_code == 403
def test_tpo_can_create_drive(as_tpo):
assert as_tpo.post("/drives", json={"company": "TCS"}).status_code == 201
def test_anonymous_gets_401(client):
assert client.post("/drives", json={"company": "TCS"}).status_code == 401
# Three tests = the permission matrix, executable. (JWT encode/decode
# itself gets separate unit tests — here we test AUTHORIZATION, not tokens.)
# ── SQLite vs Postgres-in-Docker for the test DB ──
# SQLite in-memory: instant, free, CI-trivial
# but: no JSONB/ARRAY, looser types, different constraint timing,
# no CONCURRENTLY, ILIKE differences — code using these will pass
# tests and fail production (or vice versa)
# Postgres via docker/testcontainers: identical semantics, ~2s startup
# pytest fixture: testcontainers.postgres.PostgresContainer("postgres:16")
# Rule: ORM-portable CRUD → SQLite is fine. Postgres-specific SQL,
# JSONB fields, or migration tests → test against real Postgres.
# (Alembic migrations are ALSO testable: upgrade head on a fresh
# container, assert schema — catches the autogenerate misses.)Key Points to Remember
- 1dependency_overrides[get_db] → test DB; [get_current_user] → canned identities
- 2Transaction-per-test with rollback gives isolation without table re-creation
- 3Always clear overrides after each test — leaked fakes cause haunted failures
- 4SQLite for portable CRUD speed; Postgres-in-Docker when DB semantics matter
Interview Questions
Sign in to ask AriaDesign test isolation for a FastAPI + Postgres service — schema, transactions, fixtures.
When does testing on SQLite lie to you about Postgres behaviour? Three concrete cases.
Why override get_current_user instead of minting real JWTs in tests? When would you do the opposite?
Ask Aria about Test Fixtures & Overrides — Real Tests, Fake Infrastructure
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.