Testing — Cheat Sheet
FastAPI · 3 topics. Download the PDF or the Instagram carousel and share it.
Cheat Sheet · AiCanCode.org
Testing
FastAPI3 topicsQuick revision reference
1
TestClient — Testing Endpoints with pytest
TestClient calls your app in-process with the requests API — no server, no network. Test the contract: status codes, response shapes, validation rejections, and auth gates, with pytest.mark.parametrize doing the heavy lifting.
- ✓TestClient runs the app in-process: full stack, no server, hundreds of tests/second
- ✓Test the contract: status codes, JSON shape, and the password-leak check
- ✓parametrize turns validation rules into readable, exhaustive tables
- ✓Assert WHICH field caused the 422 via loc, not just that one happened
Status + shape + the leak check + both sides of the auth gate
# pip install pytest httpx
# tests/test_students.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app) # module-level is fine for stateless tests
def test_get_student_returns_profile():
resp = client.get("/students/1")
assert resp.status_code == 200
body = resp.json()
assert body["name"] == "Asha"
assert "password" not in body # response_model filtering WORKS
assert "hashed" not in body # (the leak test worth writing)
def test_missing_student_is_404_with_detail():
resp = client.get("/students/99999")
assert resp.status_code == 404
assert "not found" in resp.json()["detail"]
def test_create_student_returns_201_and_id():
resp = client.post("/students", json={
"name": "Ravi", "email": "ravi@coep.ac.in",
"branch": "IT", "cgpa": 7.9,
})
assert resp.status_code == 201
assert resp.json()["id"] > 0
def test_protected_route_rejects_anonymous():
resp = client.get("/admin/students") # no Authorization header
assert resp.status_code == 401
def test_protected_route_accepts_token():
resp = client.get("/admin/students",
headers={"Authorization": "Bearer test-tpo-token"})
assert resp.status_code == 200
# Naming: test_<what>_<expectation> — failures read as sentences
# in CI output: "test_missing_student_is_404_with_detail FAILED" tells
# the story before you open the file.2
Test Fixtures & Overrides — Real Tests, Fake Infrastructure
dependency_overrides swaps get_db for a per-test SQLite/test-Postgres session and get_current_user for canned users — pytest fixtures wire it so every test starts on a clean, isolated world.
- ✓dependency_overrides[get_db] → test DB; [get_current_user] → canned identities
- ✓Transaction-per-test with rollback gives isolation without table re-creation
- ✓Always clear overrides after each test — leaked fakes cause haunted failures
- ✓SQLite for portable CRUD speed; Postgres-in-Docker when DB semantics matter
Schema once, transaction per test, rollback = isolation
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.core.db import Base, get_db
# SQLite in-memory: fast, zero setup. (Postgres-in-Docker when you rely on
# JSONB/arrays/constraint semantics — see notes below.)
engine = create_engine("sqlite://",
connect_args={"check_same_thread": False})
TestSession = sessionmaker(bind=engine)
@pytest.fixture(scope="session", autouse=True)
def create_schema():
Base.metadata.create_all(engine) # once per test run
yield
Base.metadata.drop_all(engine)
@pytest.fixture
def db():
"""Each test runs inside a transaction that is rolled back."""
connection = engine.connect()
trans = connection.begin()
session = TestSession(bind=connection)
yield session
session.close()
trans.rollback() # ← test's writes vanish
connection.close()
@pytest.fixture
def client(db):
app.dependency_overrides[get_db] = lambda: (yield db)
with TestClient(app) as c: # 'with' runs lifespan too
yield c
app.dependency_overrides.clear() # NEVER leak overrides across tests
# tests/test_students_db.py — tests are now clean and oblivious:
def test_create_then_fetch(client):
created = client.post("/students", json={
"name": "Asha", "email": "asha@nitk.edu.in",
"branch": "CS", "cgpa": 8.7}).json()
fetched = client.get(f"/students/{created['id']}")
assert fetched.json()["name"] == "Asha"
def test_previous_test_left_nothing_behind(client):
assert client.get("/students").json()["total"] == 0 # rollback proof3
Async Tests — pytest-asyncio & AsyncClient
httpx.AsyncClient over ASGITransport calls the app inside a real event loop — required when tests must await alongside requests (async DB checks, gather, WebSockets). pytest-asyncio's loop-scope config is the gotcha.
- ✓AsyncClient + ASGITransport = awaitable in-process calls for async-native tests
- ✓asyncio_default_fixture_loop_scope fixes the "different loop" fixture error
- ✓asyncio.gather in tests catches race conditions TestClient cannot produce
- ✓WebSockets test via sync TestClient.websocket_connect; SSE via client.stream
await seed → await call → await assert; gather finds races
# pip install pytest-asyncio
# pyproject.toml:
# [tool.pytest.ini_options]
# asyncio_mode = "auto" # async def tests just work
# asyncio_default_fixture_loop_scope = "session" # ← THE gotcha fix:
# fixtures + tests share one loop; without it, session-scoped async
# fixtures die with "Future attached to a different loop"
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
async def aclient():
transport = ASGITransport(app=app) # in-process, no server — TestClient's
async with AsyncClient(transport=transport,
base_url="http://test") as c:
yield c
async def test_create_student(aclient, async_db): # async db fixture
# arrange — AWAIT the seed (impossible around sync TestClient):
await seed_students(async_db, count=3)
# act
resp = await aclient.get("/students?branch=CS")
# assert on response AND database state:
assert resp.status_code == 200
row = (await async_db.execute(
select(func.count()).select_from(Student))).scalar()
assert row == 3
# ── concurrency bugs only async tests can catch ──
import asyncio
async def test_no_double_registration_race(aclient):
"""Two simultaneous registrations for the last seat: exactly one wins."""
results = await asyncio.gather(
aclient.post("/drives/7/register/1"),
aclient.post("/drives/7/register/1"),
)
codes = sorted(r.status_code for r in results)
assert codes == [201, 409] # one created, one conflict — not [201, 201]!
# TestClient cannot fire truly concurrent requests; gather can.
# This test catches the missing unique-constraint/locking bug.Learn this free with Aria, your AI tutor → AiCanCode.org/learn/fastapi