All writing
fastapi pytest testing python

FastAPI testing with pytest: practical patterns for reliable code

Ayush Kaushik 8 min read
FastAPI testing with pytest: practical patterns for reliable code

FastAPI testing with pytest is straightforward once you have the right scaffolding. In a few minutes you can spin up a TestClient, hit both sync and async endpoints, and get deterministic results without touching a real database. Below I’ll show you exactly how I set it up in production, the shortcuts that bite me, and the trade-offs you should keep in mind.


How do I set up pytest for a FastAPI project?

The first step is to make pytest aware of your application package and any fixtures you need. I keep a tests/ directory at the repo root and add a tiny conftest.py that creates the FastAPI app instance and a shared TestClient.

conftest.py
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from myapp.main import create_app # factory that builds the FastAPI instance
@pytest.fixture(scope="session")
def app() -> FastAPI:
"""Create a single FastAPI app for the whole test session."""
return create_app()
@pytest.fixture(scope="session")
def client(app: FastAPI) -> TestClient:
"""Wrap the app in a TestClient."""
return TestClient(app)

A factory function (create_app) is a must-have. It lets you inject different settings (e.g., a test config) without polluting the production startup path. I keep my settings in Pydantic models and switch to a TestSettings class in CI.

settings.py
from pydantic import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://prod:password@db/prod"
class TestSettings(Settings):
database_url: str = "sqlite+aiosqlite:///./test.db"

When create_app receives a settings object you can hand it TestSettings in the fixture. This isolates the test environment from production credentials – a mistake that has cost me a few hard-to-track bugs.


Can I use TestClient for both sync and async endpoints?

Yes, TestClient works for both. Under the hood it runs the ASGI app in a thread-pooled httpx client, so you can call async routes just like sync ones.

def test_sync_hello(client: TestClient):
response = client.get("/hello")
assert response.status_code == 200
assert response.json() == {"msg": "hello"}
@pytest.mark.asyncio
async def test_async_echo(client: TestClient):
payload = {"msg": "ping"}
response = client.post("/echo", json=payload)
assert response.status_code == 200
assert response.json() == payload

Notice the @pytest.mark.asyncio decorator. It tells pytest to run the test in an event loop. If you forget it, the test will hang because the coroutine never gets scheduled. I once ran a whole suite without the decorator and spent an hour chasing a silent timeout.


How do I override dependencies in tests?

FastAPI’s dependency_overrides dict is a clean way to replace heavy dependencies (like external APIs or auth services) with lightweight mocks.

# tests/conftest.py (continued)
from myapp.dependencies import get_current_user, get_redis
@pytest.fixture(autouse=True)
def override_deps(app: FastAPI):
async def fake_user():
class User:
id = 1
username = "test_user"
return User()
async def fake_redis():
class DummyRedis:
async def get(self, key): return None
async def set(self, key, value, ex=None): pass
return DummyRedis()
app.dependency_overrides[get_current_user] = fake_user
app.dependency_overrides[get_redis] = fake_redis
yield
app.dependency_overrides.clear()

With autouse=True the overrides apply to every test automatically. This saves you from sprinkling client.app.dependency_overrides[...] = ... in each test file. The pattern works for both sync and async dependencies. Just remember to clear the overrides after the test session; otherwise you’ll leak state into later runs.


What’s the best way to test authentication and security schemes?

FastAPI ships with OAuth2 password flow, API keys, and custom schemes. The trick is to generate a valid token once and reuse it across tests. I store the token in a module-level fixture.

tests/auth_fixtures.py
import pytest
from httpx import AsyncClient
@pytest.fixture(scope="session")
async def auth_token(app: FastAPI):
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.post("/token", data={"username": "alice", "password": "secret"})
return response.json()["access_token"]

Now any endpoint that requires Depends(oauth2_scheme) can be exercised with the header:

def test_protected_route(client: TestClient, auth_token: str):
headers = {"Authorization": f"Bearer {auth_token}"}
response = client.get("/protected", headers=headers)
assert response.status_code == 200
assert response.json()["user"] == "alice"

If you’re using a third-party SSO provider, mock the introspection endpoint with responses or httpx-mock. Don’t hit the real provider during CI – it adds latency and can get you rate-limited.


How do I manage database fixtures and transaction rollbacks?

Testing with a real DB gives confidence, but you need isolation. I prefer a per-test transaction that rolls back automatically. With SQLAlchemy 2.0 async support the pattern looks like this:

tests/db_fixtures.py
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from myapp.db import Base, get_db
from myapp.settings import TestSettings
engine = create_async_engine(TestSettings().database_url, echo=False)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@pytest.fixture(scope="function")
async def db_session():
async with AsyncSessionLocal() as session:
# start a nested transaction (SAVEPOINT)
async with session.begin_nested():
yield session
# rollback happens automatically when the context exits

Then override the get_db dependency to return this session:

# conftest.py (continued)
from myapp.dependencies import get_db
from .db_fixtures import db_session
@pytest.fixture(autouse=True)
def override_db(app: FastAPI, db_session):
async def _get_test_db():
yield db_session
app.dependency_overrides[get_db] = _get_test_db
yield
app.dependency_overrides.clear()

Each test runs inside a SAVEPOINT, so even if you commit inside the test the outer transaction rolls back when the fixture tears down. This keeps the test DB pristine without recreating the schema each run. The downside: it only works with databases that support SAVEPOINT (Postgres, MySQL). SQLite in memory works, but you lose some concurrency semantics. If you need full isolation across processes, spin up a fresh Docker container per test suite – heavy, but sometimes necessary.


When should I avoid the TestClient approach?

TestClient is perfect for unit-style API tests, but it doesn’t exercise the full ASGI server stack. If you rely on middleware that interacts with the network (e.g., HTTP/2, custom TLS termination) you’ll need an integration test that runs the app with a real server like uvicorn. Also, heavy load tests belong to a separate suite that uses locust or hey; TestClient can’t simulate concurrent users accurately.


Real-world pitfalls I’ve hit

  1. MissingGreenlet errors – When I first switched to async SQLAlchemy I forgot to install greenlet. The error surfaced only in tests because the sync test client spawns a thread. The fix is documented in my post about Fixing SQLAlchemy MissingGreenlet Error in FastAPI (Async Explained).

  2. Environment leakage – Global state in a module (e.g., a singleton cache) survived between tests. I solved it by resetting the cache in a fixture’s teardown step.

  3. CI timeouts – Running the full DB fixture suite on GitHub Actions took >10 minutes. I introduced a --fast marker that skips heavy integration tests on PR builds and only runs them on the nightly pipeline. The trade-off is slower feedback for complex scenarios, but the overall CI cost dropped dramatically.


Putting it all together

Here’s a minimal end-to-end test file that touches every piece we discussed:

tests/test_user_flow.py
import pytest
from fastapi.testclient import TestClient
def test_register_and_login(client: TestClient):
# Register a new user
reg = client.post("/register", json={"username": "bob", "password": "pwd123"})
assert reg.status_code == 201
# Login to get a token
token_resp = client.post("/token", data={"username": "bob", "password": "pwd123"})
assert token_resp.status_code == 200
token = token_resp.json()["access_token"]
# Access a protected endpoint
headers = {"Authorization": f"Bearer {token}"}
protected = client.get("/me", headers=headers)
assert protected.status_code == 200
assert protected.json()["username"] == "bob"
@pytest.mark.asyncio
async def test_async_data_flow(client: TestClient):
# Assume an async endpoint that writes to DB
payload = {"title": "Test note", "content": "Hello"}
resp = client.post("/notes", json=payload)
assert resp.status_code == 201
note_id = resp.json()["id"]
# Fetch the note back
get_resp = client.get(f"/notes/{note_id}")
assert get_resp.status_code == 200
data = get_resp.json()
assert data["title"] == payload["title"]
assert data["content"] == payload["content"]

Running pytest -q now executes both sync and async paths, uses the overridden dependencies, rolls back DB changes, and never touches production credentials. The same suite runs unchanged when I deploy to Google Cloud Run – see my article on Serverless Python: Deploying FastAPI to Google Cloud Run with Docker for the CI/CD wiring.


FAQ

How do I test websockets with pytest?
Create an AsyncClient from httpx and use client.ws_connect("/ws"). Remember to mark the test with @pytest.mark.asyncio and close the connection after assertions.

Can I use pytest-asyncio with the built-in TestClient?
Yes. The TestClient works in an async test as long as you wrap calls in await client.get(...) via client.__call__ which returns a coroutine. Most people prefer httpx.AsyncClient for pure async tests.

What’s the fastest way to spin up a test DB?
For PostgreSQL, use docker compose up -d db-test and point TestSettings.database_url to that container. Keep the container alive across the test session to avoid repeated image pulls.

Do I need to test OpenAPI schema generation?
Usually not. FastAPI guarantees schema correctness if your Pydantic models are valid. Focus on request/response behavior instead.


Key Takeaways

  • Use a factory (create_app) and a TestSettings class to keep production and test configurations separate.
  • TestClient handles both sync and async routes; just add @pytest.mark.asyncio for the latter.
  • Override heavy dependencies via app.dependency_overrides in a session-wide fixture.
  • Authenticate once per session and reuse the token to keep tests fast.
  • Wrap each test in a nested DB transaction; it rolls back automatically and gives you isolation without recreating schemas.
  • Reserve real server runs for integration or load testing; TestClient is not a replacement for those scenarios.

Happy testing!

Working on something similar?

If you're building backend or AI systems and want a second set of senior eyes, let's talk.

Keep reading

Related articles