Testing FastAPI

intermediate fastapi testing pytest production

FastAPI testing is one of the friendliest stories in Python web frameworks. We don’t spin up a real server — we use an in-process client that calls our app directly. Fast, deterministic, no port conflicts.

TestClient — the workhorse

TestClient is a thin wrapper around httpx that talks to our app via ASGI in-process. We write tests as if we were making real HTTP calls.

pip install pytest httpx
# main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
def health():
    return {"status": "ok"}

@app.post("/items")
def create_item(item: dict):
    return {"id": 1, **item}
# test_main.py
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_health():
    r = client.get("/health")
    assert r.status_code == 200
    assert r.json() == {"status": "ok"}

def test_create_item():
    r = client.post("/items", json={"name": "Book", "price": 10})
    assert r.status_code == 200
    body = r.json()
    assert body["id"] == 1
    assert body["name"] == "Book"

Run: pytest. That’s it. No server, no fixtures needed.

TestClient works for sync and async routes both — it handles the event loop internally. So even if our handler is async def, we still use the regular sync TestClient.

When to reach for httpx.AsyncClient

If we want our test code to be async (e.g., the test awaits other async things like a real Redis client or async DB session), use httpx.AsyncClient with ASGITransport.

import pytest
import httpx
from httpx import ASGITransport
from main import app

@pytest.mark.asyncio
async def test_health_async():
    transport = ASGITransport(app=app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
        r = await ac.get("/health")
    assert r.status_code == 200

Needs pytest-asyncio (or anyio plugin). Use this when the test itself needs to await something.

Dependency overrides — the killer feature

This is the single best thing about FastAPI testing. Any dependency in our app can be swapped at test time.

# main.py
from fastapi import Depends, FastAPI

def get_db():
    db = RealDB()
    try:
        yield db
    finally:
        db.close()

def get_current_user():
    # reads JWT from header, hits DB
    ...

app = FastAPI()

@app.get("/me")
def me(user = Depends(get_current_user), db = Depends(get_db)):
    return db.get_profile(user.id)

In the test, we override:

# test_main.py
from main import app, get_db, get_current_user

class FakeDB:
    def get_profile(self, user_id):
        return {"id": user_id, "name": "Test User"}

def fake_user():
    return type("User", (), {"id": 1})()

app.dependency_overrides[get_db] = lambda: FakeDB()
app.dependency_overrides[get_current_user] = fake_user

def test_me():
    r = client.get("/me")
    assert r.json()["name"] == "Test User"

# clean up afterwards
def teardown_module():
    app.dependency_overrides.clear()

In simple language: we don’t have to mock anything inside the function. We tell FastAPI “when this dependency runs, use this fake instead”. No monkeypatch, no mock.patch, no awkward wiring.

This is what makes FastAPI apps so testable — keep DB sessions, auth, external API clients, etc. behind dependencies, and tests can swap them out trivially.

Fixtures — cleaner setup

A typical conftest.py:

import pytest
from fastapi.testclient import TestClient
from main import app, get_db, get_current_user

@pytest.fixture
def client():
    app.dependency_overrides[get_db] = lambda: FakeDB()
    app.dependency_overrides[get_current_user] = fake_user
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()

def test_me(client):
    assert client.get("/me").status_code == 200

Using with TestClient(app) as c: also runs the app’s lifespan/startup/shutdown events — important if we have @app.on_event("startup") handlers.

Testing WebSockets

TestClient has websocket_connect baked in:

def test_chat():
    with client.websocket_connect("/ws/chat/manish") as ws:
        ws.send_text("hello")
        msg = ws.receive_text()
        assert "hello" in msg

Common patterns

  • Auth in tests — override get_current_user to return a fake user. Don’t deal with real tokens in unit tests.
  • DB in tests — either override get_db with an in-memory SQLite session, or use the real DB inside a transaction that gets rolled back.
  • External APIs — override the client dependency, or use respx to intercept httpx calls.
  • Don’t share state between tests — clear dependency_overrides in teardown.

Interview cheat sheet

  • TestClient(app) for 95% of cases. Sync API even for async routes.
  • httpx.AsyncClient + ASGITransport when test code itself needs to be async.
  • app.dependency_overrides[dep] = fake to swap anything — auth, DB, clients.
  • Use with TestClient(app) to trigger lifespan/startup.
  • Keep tests fast by overriding external dependencies — don’t hit real DBs or APIs.