Cheat SheetsFastAPITesting

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 proof
3

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