A well-tested FastAPI application catches regressions before they reach production, documents expected behaviour through executable examples, and gives developers the confidence to refactor. The foundation is pytest (the test runner), FastAPI’s TestClient (a synchronous HTTP client backed by the ASGI app), and a conftest.py file that sets up the fixtures every test depends on — a test database session, authenticated clients, and pre-built test data. Getting this setup right makes every subsequent test simple to write.
Installation and Configuration
pip install pytest pytest-asyncio httpx pytest-cov
# pytest.ini (or pyproject.toml [tool.pytest.ini_options])
# [pytest]
# testpaths = tests
# asyncio_mode = auto # pytest-asyncio: all async tests run automatically
# filterwarnings = ignore::DeprecationWarning
# tests/conftest.py — shared fixtures for the entire test suite
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.main import app
from app.database import Base
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.auth.password import hash_password
# ── In-memory SQLite database for fast, isolated tests ────────────────────────
# Use a real PostgreSQL test DB if the app uses PG-specific features (JSONB, etc.)
SQLITE_URL = "sqlite://" # in-memory
test_engine = create_engine(
SQLITE_URL,
connect_args = {"check_same_thread": False},
poolclass = StaticPool, # single connection, prevents threading issues
)
TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
@pytest.fixture(scope="session", autouse=True)
def create_tables():
"""Create all tables once before the test session; drop them after."""
Base.metadata.create_all(test_engine)
yield
Base.metadata.drop_all(test_engine)
@pytest.fixture
def db():
"""
Provide a session that wraps each test in a transaction and rolls back.
This isolates tests without re-creating the schema between tests.
"""
connection = test_engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
@pytest.fixture
def user(db) -> User:
"""A standard registered user for most tests."""
u = User(
email = "user@example.com",
name = "Test User",
password_hash = hash_password("Password1"),
role = "user",
is_active = True,
)
db.add(u)
db.flush()
return u
@pytest.fixture
def admin(db) -> User:
"""An admin user for admin-only endpoint tests."""
u = User(
email = "admin@example.com",
name = "Admin User",
password_hash = hash_password("Password1"),
role = "admin",
is_active = True,
)
db.add(u)
db.flush()
return u
StaticPool ensures all connections in the session share the same in-memory SQLite database (without it, each connection would get a separate in-memory database, causing the tables created by create_tables to be invisible to the test session).TEST_DATABASE_URL=postgresql://user:pass@localhost/test_db.TestClient Fixtures
# tests/conftest.py (continued)
from fastapi.testclient import TestClient
@pytest.fixture
def client(db, user) -> TestClient:
"""
TestClient authenticated as the standard test user.
Overrides get_db and get_current_user dependencies.
"""
def override_db():
yield db
def override_user():
return user
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[get_current_user] = override_user
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
@pytest.fixture
def admin_client(db, admin) -> TestClient:
"""TestClient authenticated as an admin user."""
def override_db(): yield db
def override_user(): return admin
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[get_current_user] = override_user
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
@pytest.fixture
def anon_client(db) -> TestClient:
"""TestClient with no authentication (anonymous requests)."""
def override_db(): yield db
app.dependency_overrides[get_db] = override_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
Common Mistakes
Mistake 1 — Not clearing dependency_overrides after each test
❌ Wrong — overrides leak into subsequent tests:
def test_something():
app.dependency_overrides[get_db] = override_db
TestClient(app).get("/posts")
# No cleanup — next test may still use override_db!
✅ Correct — clear in fixture teardown (after yield).
Mistake 2 — scope=”session” on the db fixture (shared state between tests)
❌ Wrong — data from one test affects another:
@pytest.fixture(scope="session")
def db(): ... # all tests share one session — mutations accumulate!
✅ Correct — scope="function" (default) with rollback per test.
Mistake 3 — Using TestClient inside async test without proper event loop
❌ Wrong — TestClient is synchronous; using it in an async test has subtle issues.
✅ Correct — use TestClient in sync tests; use httpx.AsyncClient in async tests (Lesson 4).
Quick Reference
| Task | Code |
|---|---|
| Install | pip install pytest pytest-asyncio httpx pytest-cov |
| Test client | TestClient(app) from fastapi.testclient |
| Override dependency | app.dependency_overrides[dep] = override |
| Clear overrides | app.dependency_overrides.clear() |
| Rollback pattern | Transaction → yield session → rollback |
| Run tests | pytest tests/ -v |
| Run with coverage | pytest --cov=app --cov-report=term-missing |