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 (TestClient needs httpx; 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 c

Use 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 == 409

Headers, 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 stub

Assert 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 == 200

Verify 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-missing

Results 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_overrides in the fixture, and check the session uses create_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 as pytest-asyncio or anyio.

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.