Testing with TestClient and pytest

FastAPI's design (dependency injection, app factory) makes it unusually testable: tests call your endpoints in-process with TestClient, swap real dependencies (DB, auth) for fakes with dependency_overrides, and never need a running server.

Setup and First Test

pip install pytest httpx
# test_main.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

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

def test_create_item():
    response = client.post("/items/", json={"name": "wrench", "price": 9.99})
    assert response.status_code == 201
    assert response.json()["name"] == "wrench"

def test_validation_error():
    response = client.post("/items/", json={"name": "", "price": -5})
    assert response.status_code == 422

TestClient (from Starlette, needs httpx) dispatches requests through the ASGI app directly — no network, no port.

Overriding Dependencies: Fake DB

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.main import app
from app.dependencies import get_db

test_engine = create_engine(
    "sqlite://",                       # in-memory SQLite
    connect_args={"check_same_thread": False},
    poolclass=StaticPool,              # one shared connection for all sessions
)
TestingSessionLocal = sessionmaker(bind=test_engine)
Base.metadata.create_all(bind=test_engine)

def override_get_db():
    db = TestingSessionLocal()
    try:
        yield db
    finally:
        db.close()

app.dependency_overrides[get_db] = override_get_db

@pytest.fixture(autouse=True)
def clean_db():
    yield
    # truncate tables between tests
    for table in reversed(Base.metadata.sorted_tables):
        with test_engine.begin() as conn:
            conn.execute(table.delete())

The endpoint code does not change at all — the same Depends(get_db) now receives the test session. That is the payoff of DI.

Overriding Auth in Tests

def fake_current_user():
    return User(id=1, username="tester", role="admin")

app.dependency_overrides[get_current_user] = fake_current_user

def test_admin_endpoint():
    r = client.post("/admin/users/", json={"username": "bob"})
    assert r.status_code == 201

Override per-test when needed (e.g. test the 403 path by overriding with a "guest" user) and clean up with app.dependency_overrides.clear().

Testing Authenticated / Token Flows

def test_login_and_me():
    r = client.post("/token", data={"username": "alice", "password": "secret123"})
    assert r.status_code == 200
    token = r.json()["access_token"]

    me = client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
    assert me.json()["username"] == "alice"

Note: the token endpoint takes form data (data=), JSON endpoints take json=.

Async Endpoint Tests

pip install anyio pytest
import httpx
import pytest
from app.main import app

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

TestClient runs the app in a worker thread and is fine for most cases; use httpx.AsyncClient + ASGITransport when you must test true async behavior (background tasks, websockets) in the same loop.

Quick Questions and Answers

Q: TestClient fails with "httpx not installed" — why?

A: Since Starlette 0.21+, TestClient is built on httpx. Install it: pip install httpx. Older snippets using requests belong to the Starlette versions before that.

Q: My SQLite tests pass but PostgreSQL behaves differently — is that OK?

A: In-memory SQLite is fast and great for endpoint logic, but it does not enforce all PostgreSQL types/constraints (arrays, JSONB, enums, some transactions). Keep fast SQLite tests for the suite and add a smaller integration tier against real PostgreSQL (e.g. via Docker/testcontainers).

Q: How do I test WebSockets?

A: TestClient supports a context manager: with client.websocket_connect("/ws/echo") as ws: ws.send_text("hi"); assert ws.receive_text() == "Echo: hi".

Conclusion

The testing triangle is TestClient (call endpoints), dependency_overrides (swap DB/auth for fakes), and pytest fixtures (fresh state per test). Because overrides work through the same DI system your app uses, tests exercise real routing, validation and serialization — not mocks of your own framework.