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.
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.
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.
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().
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=.
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.
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.
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).
A: TestClient supports a context manager: with client.websocket_connect("/ws/echo") as ws: ws.send_text("hi"); assert ws.receive_text() == "Echo: hi".
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.