Overview
FastAPI’s TestClient calls the ASGI app in-process, so tests need no running server. The core technique is app.dependency_overrides: replace the database session with one that rolls back after each test, and replace outbound service clients with stubs. Base pytest setup is in set-up-pytest; how dependencies resolve is in fastapi-dependencies.
Prerequisites
- A FastAPI project with a virtual environment and a reachable test Postgres database (set-up-postgres-locally).
pip install pytest httpx(TestClientneedshttpx;fastapi[standard]includes it).- An app factory such as
create_app(), so tests can build a fresh app and override its dependencies.
Steps
1. App and client fixtures
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from myapp.main import create_app
@pytest.fixture
def app():
app = create_app()
yield app
app.dependency_overrides.clear() # no override leaks into the next test
@pytest.fixture
def client(app):
with TestClient(app) as c: # the with-block runs lifespan startup and shutdown
yield cUse plain def tests and plain calls (client.get(...)), not await. Build a new TestClient per test so lifespan state does not carry over; see fastapi-dependencies.
2. Isolate the database with a rolled-back transaction
Bind a session to a connection that holds an outer transaction, and let the session use savepoints. SQLAlchemy’s documented recipe for this is join_transaction_mode="create_savepoint", so even a route that calls commit() is rolled back at the end of the test.
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from myapp.database import get_session
engine = create_engine("postgresql+psycopg://test:test@localhost/test_db")
@pytest.fixture
def db_session(app):
connection = engine.connect()
outer = connection.begin()
session = Session(bind=connection, join_transaction_mode="create_savepoint")
app.dependency_overrides[get_session] = lambda: session
yield session
session.close()
outer.rollback()
connection.close()Create the schema once per session, with migrations or Base.metadata.create_all(engine) in a session-scoped fixture. The override key must be the exact function object the route depends on, imported from the same module.
3. Test routes
def test_create_user_returns_201(client, db_session):
r = client.post("/users", json={"email": "alice@example.com", "name": "Alice"})
assert r.status_code == 201
assert r.json()["email"] == "alice@example.com"
def test_duplicate_email_is_rejected(client, db_session):
payload = {"email": "bob@example.com", "name": "Bob"}
client.post("/users", json=payload)
assert client.post("/users", json=payload).status_code == 409Headers, cookies, query parameters, form data (data=), and JSON bodies (json=) go through TestClient as with requests.
4. Stub external services
class StubEmail:
def __init__(self): self.sent = []
def send(self, to, subject, body): self.sent.append((to, subject))
@pytest.fixture
def stub_email(app):
stub = StubEmail()
app.dependency_overrides[get_email_service] = lambda: stub
return stubAssert on stub_email.sent after the request; no network call happens.
5. Test auth both ways
def test_admin_route_requires_token(client):
assert client.get("/admin/users").status_code == 401
def test_admin_route_with_token(client, db_session):
r = client.get("/admin/users", headers={"Authorization": f"Bearer {make_token(role='admin')}"})
assert r.status_code == 200Verify it worked
pytest -q
pytest -q tests/test_users.py tests/test_admin.py # then run the files in the opposite order
pytest --cov=myapp --cov-report=term-missingResults must not depend on file order. If they do, state is leaking between tests.
Common errors
- Tests pass alone and fail together. An override or committed data leaks; clear
dependency_overridesin the fixture, and check the session usescreate_savepoint. - An override does nothing. The key is not the function the route declares with
Depends; import it from the same place. - Startup code does not run. The client is used without
with TestClient(app). - Async tests hang or are skipped. Call async code from async tests with
httpx.AsyncClient(transport=ASGITransport(app=app))and an async-capable runner such aspytest-asynciooranyio.
FAQ
Do I need a running server to test FastAPI?
No; TestClient calls the ASGI app in-process, so tests need no running server.
How do I roll back the database after each FastAPI test?
Override the session dependency with a session bound to a connection holding an outer transaction and join_transaction_mode="create_savepoint", then roll back the outer transaction; even a route that calls commit() is undone.
Why does my dependency_overrides entry do nothing?
The key is not the exact function the route declares with Depends; import it from the same module.
Why doesn’t FastAPI startup code run in tests?
The client is used without with TestClient(app); the with-block runs lifespan startup and shutdown.
Why do FastAPI tests pass alone but fail together?
An override or committed data leaks between tests; clear dependency_overrides in the app fixture and check the session uses create_savepoint.