FastAPI / Chapter 5 of 8

Testing, middleware, and uploads

Test FastAPI routes with dependency overrides and async fixtures. Configure CORS and middleware, upload files, and return downloads.

4 topicsUpdated
16 / 28

Testing routes, errors, and dependencies#

TestClient sends requests into the app without a live server. Use overrides to replace database sessions or authenticated users, and clear them after each test.

PythonSelf-contained pytest tests and override cleanup
# pip install pytest httpx
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient

app = FastAPI()

def current_user():
    return {"id": 1}

@app.get("/me")
def me(user: Annotated[dict, Depends(current_user)]):
    return user

@app.get("/books/{book_id}")
def book(book_id: int):
    return {"id": book_id}

def test_protected_me():
    app.dependency_overrides[current_user] = lambda: {"id": 7}
    try:
        with TestClient(app) as client:  # runs lifespan
            response = client.get("/me")
        assert response.status_code == 200
        assert response.json()["id"] == 7
    finally:
        app.dependency_overrides.clear()

def test_validation():
    with TestClient(app) as client:
        response = client.get("/books/not-an-int")
    assert response.status_code == 422
PythonDatabase override with rollback per test
# Test database for the app's existing get_db dependency.
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.main import app
from app.db import Base, get_db
from app import models  # register mappings before create_all

@pytest.fixture
def client():
    test_engine = create_engine("sqlite://",
        connect_args={"check_same_thread": False}, poolclass=StaticPool)
    Base.metadata.create_all(test_engine)
    connection = test_engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection, join_transaction_mode="create_savepoint")

    def override_db():
        yield session

    app.dependency_overrides[get_db] = override_db
    try:
        with TestClient(app) as client:
            yield client
    finally:
        app.dependency_overrides.clear()
        session.close()
        transaction.rollback()
        connection.close()
        test_engine.dispose()

# SQLite is handy for unit tests; PostgreSQL test DB is better
# for PostgreSQL-specific constraints, locking, and SQL.
Meaningful scenarios
Exercise happy path, 422 input, 401 unauthenticated, 403 unauthorized, 404 missing object, 409 unique conflict, relationship loading, and transaction rollback. Use a production-like database for constraints and locking.
17 / 28

Async tests and lifecycle#

Async test functions use HTTPX with an ASGI transport. When startup resources matter, explicitly run the app lifespan; an HTTPX transport alone does not trigger it.

PythonAsyncClient + ASGI transport
# pip install pytest anyio httpx asgi-lifespan
import pytest
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from app.main import app

@pytest.mark.anyio
async def test_async_endpoint():
    async with LifespanManager(app):
        transport = ASGITransport(app=app)
        async with AsyncClient(transport=transport,
                               base_url="http://test") as client:
            response = await client.get("/health")
    assert response.status_code == 200
ShellRun useful tests
pytest -q
pytest tests/test_api.py -q
pytest -q -k 'authorization or relationship'

# TestClient runs startup/shutdown inside `with TestClient(app)`.
# HTTPX AsyncClient with ASGITransport alone does not.
Database testing
Override the exact callable passed to Depends, not a similar wrapper. Clear overrides even when assertions fail. Keep test state isolated; avoid sharing sessions between parallel tests.
18 / 28

CORS, middleware, trusted proxies, and settings#

CORS controls which browser origins can call your API. It is not an authorization system. Middleware wraps requests; settings should come from environment variables and be cached as a dependency.

PythonCORS and request timing
from time import perf_counter
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://dashboard.example.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PATCH", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)

@app.middleware("http")
async def add_timing(request: Request, call_next):
    start = perf_counter()
    response = await call_next(request)
    response.headers["X-Process-Time"] = f"{perf_counter() - start:.4f}"
    return response
PythonInject cached settings
from typing import Annotated
from fastapi import Depends, FastAPI
from config import Settings, get_settings

app = FastAPI()

@app.get("/info")
def info(settings: Annotated[Settings, Depends(get_settings)]):
    return {"debug": settings.debug}

# Tests may override get_settings via app.dependency_overrides.
# Avoid returning secrets from a settings endpoint.
Credentials and origins
When browser credentials are used, list explicit origins; wildcard origins cannot stand in for a trusted origin list. Limit --forwarded-allow-ips to your actual reverse proxy network; only trust forwarded headers from known proxies.
19 / 28

Forms, file uploads, and downloads#

HTML forms use form encoding or multipart data, not a JSON body. UploadFile gives a file-like object and async read methods; cap sizes at the edge and validate content.

PythonUpload in bounded chunks
import hashlib
from fastapi import FastAPI, File, Form, HTTPException, UploadFile

app = FastAPI()

@app.post("/documents")
async def upload_document(
    title: str = Form(),
    file: UploadFile = File(),
):
    allowed = {"application/pdf"}
    if file.content_type not in allowed:
        raise HTTPException(415, "PDF required")
    size = 0
    digest = hashlib.sha256()
    try:
        while chunk := await file.read(1024 * 1024):
            size += len(chunk)
            if size > 10 * 1024 * 1024:
                raise HTTPException(413, "Too large")
            digest.update(chunk)  # process each chunk without buffering the file
        return {"title": title, "filename": file.filename,
                "size": size, "sha256": digest.hexdigest()}
    finally:
        await file.close()
PythonSend a file and a stream
from fastapi.responses import FileResponse, StreamingResponse

@app.get("/reports/latest")
def report():
    return FileResponse("/private/reports/latest.pdf",
                        media_type="application/pdf",
                        filename="latest.pdf")

def rows():
    yield "id,name\n"
    for i in range(3):
        yield f"{i},book-{i}\n"

@app.get("/export")
def export():
    return StreamingResponse(rows(), media_type="text/csv")
File safety
content_type and filename are supplied by the client; inspect file contents, sanitize filenames, and enforce maximum size in a proxy or gateway too. A streaming generator must retain the resources it needs until it finishes.