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.tomland 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
FastAPIinstance. 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 cscope="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 == 409Request 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 == 200Verify 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. Thedb_sessionfixture 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()anddependency_overrides.clear(). httpx.ConnectErrorin TestClient. The app startup event failed. Check the lifespan context increate_app(); see fastapi-lifespan.- Dependency override has no effect. The key in
dependency_overridesmust 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 usepytest-asynciofor async test functions.