Overview

FastAPI’s TestClient wraps the ASGI app in a synchronous interface so pytest can call routes without a running server. The key pattern is dependency overrides: swap real database sessions for transactions that roll back after each test, and replace external service clients with stubs. The broader pytest setup lives in set-up-pytest.

Prerequisites

  • A FastAPI project with pyproject.toml and a virtual environment active.
  • pip install "fastapi[standard]" pytest pytest-asyncio httpx (TestClient uses httpx internally).
  • A Postgres database reachable for integration tests. See set-up-postgres-locally.
  • The app factory pattern: a function that creates and returns the FastAPI instance. Avoid module-level app creation, which complicates overrides.

Steps

1. Create a conftest.py with the app fixture

Place conftest.py at the root of the tests/ directory. The app fixture creates a clean instance; client wraps it.

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from myapp.main import create_app
from myapp.database import get_session, Base, engine
 
@pytest.fixture(scope="session")
def app():
    application = create_app()
    Base.metadata.create_all(bind=engine)
    yield application
    Base.metadata.drop_all(bind=engine)
 
@pytest.fixture
def client(app):
    with TestClient(app) as c:
        yield c

scope="session" creates the database schema once. The client fixture is function-scoped, so each test starts a fresh TestClient context. See fastapi-dependencies for how FastAPI resolves dependency graphs.

2. Override the database dependency for isolation

Use app.dependency_overrides to swap the real session for a test session that wraps each test in a rolled-back transaction.

# tests/conftest.py (continued)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
 
TEST_DATABASE_URL = "postgresql://test:test@localhost/test_db"
 
test_engine = create_engine(TEST_DATABASE_URL)
TestSessionLocal = sessionmaker(bind=test_engine)
 
@pytest.fixture
def db_session(app):
    connection = test_engine.connect()
    transaction = connection.begin()
    session = TestSessionLocal(bind=connection)
 
    def override_get_session():
        yield session
 
    app.dependency_overrides[get_session] = override_get_session
    yield session
    session.close()
    transaction.rollback()
    connection.close()
    app.dependency_overrides.clear()

The rollback after yield ensures no test data persists to the next test. Pass db_session as a fixture to any test that writes to the database.

3. Write route tests with TestClient

# tests/test_users.py
def test_create_user_returns_201(client, db_session):
    payload = {"email": "alice@example.com", "name": "Alice"}
    response = client.post("/users", json=payload)
    assert response.status_code == 201
    body = response.json()
    assert body["email"] == "alice@example.com"
    assert "id" in body
 
def test_create_user_rejects_duplicate_email(client, db_session):
    payload = {"email": "bob@example.com", "name": "Bob"}
    client.post("/users", json=payload)
    response = client.post("/users", json=payload)
    assert response.status_code == 409

Request headers, authentication tokens, and query parameters all pass through the TestClient interface in the same way as requests. See fastapi for route patterns.

4. Stub external service dependencies

Avoid calling real external APIs in tests. Use dependency_overrides to replace service clients.

# tests/conftest.py (continued)
from myapp.services import get_email_service
 
class StubEmailService:
    def __init__(self):
        self.sent: list[dict] = []
 
    def send(self, to: str, subject: str, body: str) -> None:
        self.sent.append({"to": to, "subject": subject})
 
@pytest.fixture
def stub_email(app):
    stub = StubEmailService()
    app.dependency_overrides[get_email_service] = lambda: stub
    yield stub
    app.dependency_overrides.pop(get_email_service, None)

In tests, assert on stub_email.sent after calling the route. No real emails are sent.

5. Test authentication

Pass tokens as headers; test both authenticated and unauthenticated paths.

def test_protected_route_requires_auth(client):
    response = client.get("/admin/users")
    assert response.status_code == 401
 
def test_protected_route_with_valid_token(client, db_session):
    token = create_test_token(user_id=1, role="admin")
    response = client.get(
        "/admin/users",
        headers={"Authorization": f"Bearer {token}"},
    )
    assert response.status_code == 200

Verify it worked

pytest tests/ -v --tb=short
# All tests pass; no database state leaks between tests.
 
pytest --collect-only | grep "<Function"
# Every route has at least one test function.
 
pytest --cov=myapp --cov-report=term-missing
# Route handlers covered; dependency override paths covered.

Common errors

  • RuntimeError: Session is already closed. The db_session fixture was not passed to the test that calls a route writing to the database. Add it as a parameter.
  • Tests pass individually but fail together. Test order matters; state is leaking. Check that all fixtures use transaction.rollback() and dependency_overrides.clear().
  • httpx.ConnectError in TestClient. The app startup event failed. Check the lifespan context in create_app(); see fastapi-lifespan.
  • Dependency override has no effect. The key in dependency_overrides must be the exact callable object imported into the router module, not a re-imported copy. Use the same import path everywhere.
  • Async route tests hang. Add asyncio_mode = "auto" to [tool.pytest.ini_options] and use pytest-asyncio for async test functions.