Testing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13, Starlette 1.7 and pytest 9.1, checked on
2026-10-02. FastAPI supports Python 3.10 through 3.14; every example below was executed on Python 3.13 with pytest, and the
outputs shown are from those runs (the long path of the virtual environment is shortened to .venv). The running scenario is
the Bookshelf API (books, authors and reviews). FastAPI applications are unusually easy to test: the application is an ordinary ASGI
callable, TestClient calls it in-process without opening a socket, and app.dependency_overrides replaces the database, the
settings or the identity provider with a line of code. This page shows the whole toolbox on one small application and
one test suite, from the first test to the test pyramid and coverage. It does not re-explain pytest itself; fixtures, parametrization,
markers and mocking basics are on Unit Testing with pytest & Mocking.
In a Hurry: What to Use for What
| You want to test | Use | Section |
|---|---|---|
A path operation end to end (routing, validation, serialization, status codes) |
|
The First Test: TestClient and pytest, Writing Tests: Requests, Responses, Validation and Errors |
The same path operation without a real database, identity provider or clock |
|
|
A route behind login |
A minted token, a real login call or an overridden user dependency |
|
Startup and shutdown code |
|
|
A WebSocket endpoint |
|
|
Code that must |
|
|
The data actually written to the database |
An in-memory SQLite engine with |
|
Pure business rules |
Plain unit tests with |
|
Which lines the tests never reach |
|
|
Hundreds of generated cases or load |
Schemathesis, Hypothesis and Locust |
The First Test: TestClient and pytest
fastapi.testclient.TestClient is Starlette’s test client re-exported for convenience (starlette.testclient.TestClient is the
same class). It is an HTTP client in the shape of httpx whose transport calls your ASGI application directly in the same
process. There is no server, no port and no network, so a test takes milliseconds. Install the test tools with
pip install pytest httpx (the official tutorial uses uv add httpx); the section The httpx and httpx2 Warning in Starlette 1.7 explains why a
newer package, httpx2, is now preferred.
A test is a function whose name starts with test_; it builds a TestClient for the application, makes requests with the usual
get, post, put, patch and delete methods and asserts on the Response.
# app/main.py
from fastapi import FastAPI
app = FastAPI(title="Bookshelf API")
@app.get("/")
async def read_root() -> dict[str, str]:
return {"name": "Bookshelf API"}
@app.get("/books/{book_id}")
def read_book(book_id: int) -> dict[str, int | str]:
return {"id": book_id, "title": "The Left Hand of Darkness"}
Source: FastAPI: Testing
# tests/test_main.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_read_root() -> None:
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"name": "Bookshelf API"}
def test_read_book_validates_the_path_parameter() -> None:
assert client.get("/books/3").json()["id"] == 3
response = client.get("/books/three")
assert response.status_code == 422
assert response.json()["detail"][0]["type"] == "int_parsing"
Source: FastAPI: Testing
Run it from the project root with pytest. The output is real, including the warning that Starlette 1.7 prints when the test client
runs on plain httpx (see The httpx and httpx2 Warning in Starlette 1.7):
pytest -q
.. [100%]
=============================== warnings summary ===============================
../../../venv/lib/python3.13/site-packages/fastapi/testclient.py:1
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
2 passed, 1 warning in 0.80s
Three details of the example matter for everything that follows:
-
The test functions are plain
def, and the client calls are plain calls, notawait.TestClientruns the application on an event loop in a helper thread (an AnyIO blocking portal) and hands you the result synchronously, so pytest needs no plugin. Anasync defendpoint and adefendpoint (run in the thread pool) are tested identically. -
The application is exercised through its public interface only: a URL in, a response out. The same test passes if you rewrite the endpoint, split it into routers or change the dependencies underneath (see Project Structure & Layered Architecture).
-
Validation is part of the application. The second test proves that the path parameter conversion in Path Parameters rejects
threewith422and the Pydantic error typeint_parsing.
What TestClient Does
def test_x(): client.get('/books/3')"] C["TestClient
(an httpx-style client)"] TR["test transport
builds the ASGI scope"] P["AnyIO blocking portal
event loop in a helper thread"] A["ASGI app
middleware, router, dependencies,
path function, response model"] T -->|"synchronous call"| C --> TR --> P -->|"await app(scope, receive, send)"| A A -->|"ASGI messages"| P --> TR --> C -->|"Response"| T S[("no socket, no port, no server process")] -.- TR
Everything in the box on the right really runs: middleware, exception handlers, dependency resolution, validation and
serialization. What does not run is the server: there is no HTTP parsing, no TLS, no HTTP/2, no proxy headers, no Server header from
Uvicorn, no real timing and no multiple workers. Behaviour that depends on those must be checked against a real server (see
Deployment).
Test Layout and pytest Configuration
Keep tests in a tests directory next to the app package, mirror the application’s modules in the file names, and put shared
fixtures in tests/conftest.py, which pytest imports automatically and which makes the fixtures available to every test below it.
bookshelf/
├── pyproject.toml
├── app/
│ ├── __init__.py
│ ├── config.py # pydantic-settings, cached get_settings()
│ ├── db.py # engine, get_session dependency
│ ├── models.py # SQLModel tables and request models
│ ├── security.py # OAuth2 password flow, JWT, current user
│ ├── service.py # pure business logic
│ ├── isbn.py # client for an external service
│ ├── rooms.py # WebSocket rooms
│ └── main.py # the FastAPI app and its path operations
└── tests/
├── conftest.py # fixtures: settings, session, client, auth
├── test_books.py
├── test_auth.py
├── test_overrides.py
├── test_lifespan.py
├── test_websockets.py
├── test_review_feed.py
├── test_async.py
├── test_database.py
├── test_service.py
└── ...
The official tutorial puts a test_main.py inside the package and uses relative imports. A separate tests directory is the usual choice
for real projects: the tests are not shipped with the package, and the import from app.main import app works as soon as pytest knows
where the project root is. Say so in pyproject.toml instead of fiddling with sys.path:
[tool.pytest.ini_options]
testpaths = ["tests"] # where pytest looks when run without arguments
pythonpath = ["."] # the project root is importable: "from app.main import app"
Source: pytest: Configuration Options
Day-to-day commands that every project needs:
pytest # everything under testpaths
pytest tests/test_auth.py # one file
pytest tests/test_books.py::test_unknown_book_is_404 # one test, by node id
pytest -k "expired or wrong" tests/test_auth.py # tests whose name matches an expression
pytest -x --lf # stop at the first failure, rerun the last failures
pytest -v tests/test_auth.py # one line per test
tests/test_auth.py::test_login_returns_a_token_that_works PASSED [ 16%]
tests/test_auth.py::test_wrong_password_is_401 PASSED [ 33%]
tests/test_auth.py::test_missing_token_is_401 PASSED [ 50%]
tests/test_auth.py::test_expired_token_is_401 PASSED [ 66%]
tests/test_auth.py::test_token_signed_with_another_key_is_401 PASSED [ 83%]
tests/test_auth.py::test_override_the_user_instead_of_logging_in PASSED [100%]
============================== 6 passed in 0.21s ===============================
.. [100%]
2 passed, 4 deselected in 0.13s
Name the files test_*.py and the functions test_*; that is pytest’s discovery convention, and nothing about FastAPI changes it.
Run the tests from the project root so relative paths in the application (templates, static files, .env) resolve the same way as in
production.
The Application Under Test
The tests on this page exercise one application. It is intentionally ordinary: settings from the environment, SQLModel tables, a session dependency, a login endpoint that returns a JWT, an external-service client, a lifespan and two WebSocket endpoints. Read it once; the sections below only refer back to it. The files are small, and each of them is a normal FastAPI module covered on another page of this section.
from functools import lru_cache
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="BOOKSHELF_", env_file=".env")
database_url: str = "sqlite:///bookshelf.db"
jwt_secret: SecretStr
token_minutes: int = 30
@lru_cache
def get_settings() -> Settings:
return Settings()
from sqlmodel import Field, SQLModel
class AuthorCreate(SQLModel):
name: str = Field(min_length=1, max_length=100)
class Author(AuthorCreate, table=True):
id: int | None = Field(default=None, primary_key=True)
class BookCreate(SQLModel):
title: str = Field(min_length=1, max_length=200)
author_id: int
year: int | None = Field(default=None, ge=1400, le=2100)
class Book(BookCreate, table=True):
id: int | None = Field(default=None, primary_key=True)
author_id: int = Field(foreign_key="author.id")
class ReviewCreate(SQLModel):
rating: int = Field(ge=1, le=5)
text: str = Field(default="", max_length=2000)
class Review(ReviewCreate, table=True):
id: int | None = Field(default=None, primary_key=True)
book_id: int = Field(foreign_key="book.id")
reviewer: str
from collections.abc import Iterator
from typing import Annotated
from fastapi import Depends
from sqlmodel import Session, create_engine
from app.config import get_settings
_engine = None
def get_engine():
global _engine
if _engine is None:
_engine = create_engine(get_settings().database_url)
return _engine
def get_session() -> Iterator[Session]:
with Session(get_engine()) as session:
yield session
SessionDep = Annotated[Session, Depends(get_session)]
from datetime import UTC, datetime, timedelta
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from pwdlib import PasswordHash
from app.config import Settings, get_settings
password_hash = PasswordHash.recommended()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
# username -> password hash; a real application reads these from the database
USERS = {"ada": password_hash.hash("ada-test-password")}
def create_access_token(username: str, settings: Settings) -> str:
expires = datetime.now(UTC) + timedelta(minutes=settings.token_minutes)
return jwt.encode(
{"sub": username, "exp": expires},
settings.jwt_secret.get_secret_value(),
algorithm="HS256",
)
def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
settings: Annotated[Settings, Depends(get_settings)],
) -> str:
try:
payload = jwt.decode(
token, settings.jwt_secret.get_secret_value(), algorithms=["HS256"]
)
except jwt.InvalidTokenError:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
) from None
return payload["sub"]
CurrentUser = Annotated[str, Depends(get_current_user)]
# app/service.py
def rating_summary(ratings: list[int]) -> dict[str, float | int | None]:
"""Pure business logic: no FastAPI, no database."""
if not ratings:
return {"count": 0, "average": None}
return {"count": len(ratings), "average": round(sum(ratings) / len(ratings), 2)}
# app/isbn.py
import httpx
class IsbnLookup:
"""Talks to an external service: slow, rate limited, not for unit tests."""
def __init__(self, base_url: str = "https://isbn.example.com") -> None:
self.base_url = base_url
def title_for(self, isbn: str) -> str | None:
response = httpx.get(f"{self.base_url}/isbn/{isbn}", timeout=5)
return response.json().get("title") if response.status_code == 200 else None
def get_isbn_lookup() -> IsbnLookup:
return IsbnLookup()
# app/rooms.py
from collections import defaultdict
from fastapi import WebSocket
class Rooms:
"""Which sockets watch which book."""
def __init__(self) -> None:
self.by_book: dict[int, set[WebSocket]] = defaultdict(set)
async def join(self, book_id: int, websocket: WebSocket) -> None:
await websocket.accept()
self.by_book[book_id].add(websocket)
def leave(self, book_id: int, websocket: WebSocket) -> None:
self.by_book[book_id].discard(websocket)
async def broadcast(self, book_id: int, message: dict) -> None:
for websocket in list(self.by_book[book_id]):
await websocket.send_json(message)
rooms = Rooms()
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import (
Request,
BackgroundTasks,
Depends,
FastAPI,
HTTPException,
WebSocket,
WebSocketDisconnect,
status,
)
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import select
from app.config import Settings, get_settings
from app.db import SessionDep
from app.isbn import IsbnLookup, get_isbn_lookup
from app.models import Author, AuthorCreate, Book, BookCreate, Review, ReviewCreate
from app.rooms import rooms
from app.security import USERS, CurrentUser, create_access_token, password_hash
from app.service import rating_summary
audit_log: list[str] = []
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.genres = ["fiction", "history", "science"] # startup: warm a cache
yield
app.state.genres = [] # shutdown: release it
app = FastAPI(title="Bookshelf API", lifespan=lifespan)
@app.get("/genres")
def list_genres(request: Request) -> list[str]:
return request.app.state.genres
@app.post("/token")
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()],
settings: Annotated[Settings, Depends(get_settings)]) -> dict[str, str]:
stored = USERS.get(form.username)
if stored is None or not password_hash.verify(form.password, stored):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"})
return {"access_token": create_access_token(form.username, settings),
"token_type": "bearer"}
@app.post("/authors", status_code=status.HTTP_201_CREATED)
def create_author(data: AuthorCreate, session: SessionDep, user: CurrentUser) -> Author:
author = Author.model_validate(data)
session.add(author)
session.commit()
session.refresh(author)
return author
@app.post("/books", status_code=status.HTTP_201_CREATED)
def create_book(data: BookCreate, session: SessionDep, user: CurrentUser,
tasks: BackgroundTasks) -> Book:
if session.get(Author, data.author_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Author not found")
book = Book.model_validate(data)
session.add(book)
session.commit()
session.refresh(book)
tasks.add_task(audit_log.append, f"{user} created book {book.id}")
return book
@app.get("/books")
def list_books(session: SessionDep, limit: int = 20, offset: int = 0) -> list[Book]:
return list(session.exec(select(Book).offset(offset).limit(limit)))
@app.get("/books/{book_id}")
def read_book(book_id: int, session: SessionDep) -> Book:
book = session.get(Book, book_id)
if book is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
return book
@app.post("/books/{book_id}/reviews", status_code=status.HTTP_201_CREATED)
def add_review(book_id: int, data: ReviewCreate, session: SessionDep,
user: CurrentUser, tasks: BackgroundTasks) -> Review:
if session.get(Book, book_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
review = Review.model_validate(data, update={"book_id": book_id, "reviewer": user})
session.add(review)
session.commit()
session.refresh(review)
tasks.add_task(rooms.broadcast, book_id, {"rating": review.rating, "reviewer": user})
return review
@app.get("/books/{book_id}/rating")
def book_rating(book_id: int, session: SessionDep) -> dict:
ratings = list(session.exec(select(Review.rating).where(Review.book_id == book_id)))
return rating_summary(ratings)
@app.get("/isbn/{isbn}")
def title_by_isbn(isbn: str, lookup: Annotated[IsbnLookup, Depends(get_isbn_lookup)]) -> dict:
title = lookup.title_for(isbn)
if title is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Unknown ISBN")
return {"isbn": isbn, "title": title}
@app.get("/boom")
def boom() -> None:
raise RuntimeError("unexpected failure")
@app.websocket("/ws/books/{book_id}")
async def book_updates(websocket: WebSocket, book_id: int):
await websocket.accept()
try:
while True:
text = await websocket.receive_text()
await websocket.send_json({"book_id": book_id, "echo": text})
except WebSocketDisconnect:
pass
@app.websocket("/ws/books/{book_id}/reviews")
async def review_feed(websocket: WebSocket, book_id: int):
await rooms.join(book_id, websocket)
try:
while True:
await websocket.receive_text() # keep the socket open until the client leaves
except WebSocketDisconnect:
rooms.leave(book_id, websocket)
Three design choices make this application testable, and they are worth copying:
-
Everything environmental is a dependency. The settings (
get_settings), the database session (get_session), the current user (get_current_user) and the external client (get_isbn_lookup) are all reached throughDepends, so a test can replace each of them. The application never readsos.environor opens a connection at import time (see the settings dependency). -
The secret has no default.
jwt_secretis a requiredSecretStr; tests supply a random value, and a deployment without one fails at startup rather than running with a guessable key. -
Logic that does not need FastAPI lives outside it.
rating_summaryis a pure function that can be tested without a client (The Test Pyramid and Per-Layer Tests).
Fixtures: app, Settings, Database and Client
Fixtures are pytest’s way of building and tearing down what a test needs; a fixture that yield`s a value runs its teardown after
the test, even when the test fails. `tests/conftest.py holds the four fixtures that nearly every test of this suite uses.
import secrets
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine
from app.config import Settings, get_settings
from app.db import get_session
from app.main import app
from app.security import create_access_token
@pytest.fixture(name="settings")
def settings_fixture() -> Settings:
# Built from explicit values: no .env file, no real environment, a throwaway key.
return Settings(_env_file=None, jwt_secret=secrets.token_hex(32), database_url="sqlite://")
@pytest.fixture(name="session")
def session_fixture() -> Iterator[Session]:
engine = create_engine(
"sqlite://", # in-memory database
connect_args={"check_same_thread": False},
poolclass=StaticPool, # one connection shared by every thread
)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
engine.dispose() # close the shared connection explicitly
@pytest.fixture(name="client")
def client_fixture(session: Session, settings: Settings) -> Iterator[TestClient]:
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_settings] = lambda: settings
with TestClient(app) as client: # also runs the lifespan
yield client
app.dependency_overrides.clear() # never leak overrides into other tests
@pytest.fixture(name="auth")
def auth_fixture(settings: Settings) -> dict[str, str]:
token = create_access_token("ada", settings)
return {"Authorization": f"Bearer {token}"}
Source: FastAPI: Testing a Database
What each fixture does and why it is written that way:
-
settingsbuilds aSettingsobject from explicit values._env_file=Nonestops pydantic-settings from reading a developer’s real.env(a test that depends on the contents of a local file is not a test), and the secret is a freshsecrets.token_hex(32)per test, so nothing real is involved and nothing needs to be hard-coded. UsingSettings(…)directly bypasses thelru_cacheofget_settings. -
sessioncreates an in-memory SQLite database (sqlite://), creates the tables from the SQLModel metadata and yields aSession. Three details are not optional:-
poolclass=StaticPoolmakes SQLAlchemy keep one single connection. An in-memory SQLite database exists inside one connection, so with the default pool every new connection would see its own empty database. -
check_same_thread=Falselets that connection be used from other threads. Synchronous path functions run in Starlette’s thread pool, so the request code and the test code touch the connection from different threads. -
engine.dispose()after theyieldcloses the connection explicitly, which also keeps pytest’s unraisable-exception warnings quiet.
-
-
clientinstalls two overrides and builds theTestClient. It is entered withwithso that the lifespan runs (Testing the Lifespan) and all requests share one event loop and portal. Thelambda: sessionoverride means that the request code uses the sameSessionobject as the test, which makes it trivial to prepare and inspect data. The overrides are removed withapp.dependency_overrides.clear()after theyield, becauseappis a module-level object that outlives every test. -
authmints a valid bearer token with the same function the application uses, so tests of protected routes do not depend on the login endpoint (Testing Auth-Protected Routes).
Naming the fixture with @pytest.fixture(name="client") and the function client_fixture is the pattern used by the SQLModel tutorial. It avoids the
lint warning about a test parameter shadowing the fixture function and makes it clear that the parameter is the fixture, not an import.
Pytest can show the order in which fixtures are set up and torn down:
pytest -q --setup-show tests/test_books.py::test_unknown_book_is_404
SETUP F session
SETUP F settings
SETUP F client (fixtures used: session, settings)
TEARDOWN F client
TEARDOWN F settings
TEARDOWN F session
1 passed in 0.12s
session and settings are built first, client depends on both, and teardown runs in reverse order.
| Scope | Use it for | Notes |
|---|---|---|
|
The |
A fresh database per test: tests cannot influence each other, and the cost of an in-memory SQLite database is a few milliseconds |
|
A read-only dataset shared by a file, a generated key pair |
Mutation from one test is visible in the next one |
|
Starting a containerised PostgreSQL once, building an expensive model |
Combine with a per-test transaction or truncate to restore state |
Start with function scope. Widen it only when a measured suite is too slow, and then isolate tests again with a transaction that rolls back or with table truncation. A test suite that passes in one order and fails in another has almost always shared state of this kind.
Writing Tests: Requests, Responses, Validation and Errors
With the fixtures in place, a test reads like a description of the API. This file creates data through the API and reads it back:
from fastapi.testclient import TestClient
def test_create_and_read_book(client: TestClient, auth: dict[str, str]) -> None:
author = client.post("/authors", json={"name": "Ursula K. Le Guin"}, headers=auth)
assert author.status_code == 201
created = client.post(
"/books",
json={"title": "The Dispossessed", "author_id": author.json()["id"], "year": 1974},
headers=auth,
)
assert created.status_code == 201
book = created.json()
assert book["title"] == "The Dispossessed"
fetched = client.get(f"/books/{book['id']}")
assert fetched.status_code == 200
assert fetched.json() == book
def test_unknown_book_is_404(client: TestClient) -> None:
response = client.get("/books/999")
assert response.status_code == 404
assert response.json() == {"detail": "Book not found"}
def test_validation_error_is_422(client: TestClient, auth: dict[str, str]) -> None:
response = client.post("/books", json={"title": "", "author_id": 1}, headers=auth)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "title"]
assert error["type"] == "string_too_short"
Source: FastAPI: Testing
Points worth knowing:
-
Assert on status first, then on the body. A wrong status code makes the later
.json()access fail with a confusingKeyError; asserting the status first turns that into a readable failure. -
Assert on the stable parts of a validation error. Pydantic’s
locandtype(string_too_short) are stable; the human-readablemsgand the documentation URL change between releases. See Pydantic v2 Essentials for the error format and Status Codes & Error Handling forHTTPExceptionand handlers. -
Test the error responses you document.
404,409,422and the authentication failures are part of the API contract just like200.
The TestClient API
TestClient is an httpx-style client; everything in the HTTPX documentation about building requests applies. These are the knobs used most often:
| Argument | Effect |
|---|---|
|
Query string; a list value repeats the key ( |
|
JSON body with |
|
URL-encoded form fields ( |
|
Multipart upload for |
|
Request headers for this call; |
|
Cookies; cookies set by a response are kept in the client’s cookie jar for later calls |
|
Redirects are followed by default; turn it off to assert on |
|
Streaming responses, consumed with |
|
The client address the application sees in |
|
The host and scheme the application sees; the default is |
A second file exercises several of them against a small app: redirects, client-wide headers, cookies and address, a multipart upload with a form field, and repeated query parameters.
from typing import Annotated
from fastapi import FastAPI, Form, Request, UploadFile
from fastapi.responses import RedirectResponse
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/old")
def old() -> RedirectResponse:
return RedirectResponse("/new", status_code=307)
@app.get("/new")
def new(request: Request) -> dict:
return {"cookie": request.cookies.get("session"), "ua": request.headers["user-agent"],
"root_path": request.scope["root_path"], "client": request.client.host,
"url_for": str(request.url_for("new"))}
def test_redirects_are_followed_by_default() -> None:
client = TestClient(app)
assert client.get("/old").json()["ua"] == "testclient"
assert client.get("/old", follow_redirects=False).status_code == 307
def test_client_wide_headers_cookies_and_address() -> None:
client = TestClient(app, cookies={"session": "abc"}, headers={"user-agent": "bookshelf-tests"},
client=("203.0.113.7", 40000))
body = client.get("/new").json()
assert (body["cookie"], body["ua"], body["client"]) == ("abc", "bookshelf-tests", "203.0.113.7")
def test_root_path_is_not_prepended_to_the_url() -> None:
client = TestClient(app, root_path="/api/v1")
response = client.get("/new")
assert response.url == "http://testserver/new" # the request URL has no prefix
body = response.json()
assert body["root_path"] == "/api/v1" # but the ASGI scope carries it ...
assert body["url_for"] == "http://testserver/api/v1/new" # ... so generated URLs include it
@app.post("/covers")
async def upload_cover(file: UploadFile, title: Annotated[str, Form()]) -> dict:
return {"title": title, "filename": file.filename, "bytes": len(await file.read()),
"type": file.content_type}
def test_forms_and_file_uploads() -> None:
client = TestClient(app)
image = b"\x89PNG fake image bytes"
response = client.post(
"/covers",
data={"title": "Dune"},
files={"file": ("dune.png", image, "image/png")},
)
assert response.json() == {"title": "Dune", "filename": "dune.png", "bytes": len(image),
"type": "image/png"}
def test_query_parameters_and_json() -> None:
client = TestClient(app)
response = client.get("/new", params={"page": 2, "tag": ["sf", "classic"]})
assert response.url.query == b"page=2&tag=sf&tag=classic"
Source: Starlette: Test Client
The user-agent default is testclient, and the first test shows a header set on the client replacing it for every call. Forms and files
are described in Forms & File Uploads and cookies and headers in
Headers & Cookies.
Streaming Responses and Templates
A finite stream is tested by consuming it with client.stream(…). Infinite streams (a live feed, a clock) cannot be read to the end in a test, so test
the generator with a finite input and check the connection behaviour against a real server; see
Streaming, JSON Lines & SSE. Do not assert on the timing or boundaries of individual chunks: TestClient
does not reproduce them.
import json
from collections.abc import Iterator
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
app = FastAPI()
def export_rows() -> Iterator[str]:
for number in range(3):
yield json.dumps({"book": number}) + "\n"
@app.get("/export")
def export() -> StreamingResponse:
return StreamingResponse(export_rows(), media_type="application/x-ndjson")
def test_streamed_lines() -> None:
client = TestClient(app)
with client.stream("GET", "/export") as response:
assert response.status_code == 200
lines = [json.loads(line) for line in response.iter_lines()]
assert lines == [{"book": 0}, {"book": 1}, {"book": 2}]
Source: Starlette: Test Client
For HTML responses, response.text has the markup, and Starlette’s template responses also expose the template and the context
through the test client’s debug extension, which lets a test assert on the data without parsing HTML:
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.testclient import TestClient
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
app = FastAPI()
@app.get("/page/{title}", response_class=HTMLResponse)
def page(request: Request, title: str):
return templates.TemplateResponse(request, "book.html", {"title": title})
def test_template_name_and_context() -> None:
response = TestClient(app).get("/page/Dune")
assert response.text.strip() == "<h1>Dune</h1>"
assert response.template.name == "book.html" # which template was rendered
assert response.context["title"] == "Dune" # with which data
Source: Starlette: Templates
The Jinja2Templates side of this is on Static Files, Templates & Frontend. Starlette’s
current signature is TemplateResponse(request, name, context); the older form that put the request inside the context dictionary is deprecated.
Unhandled Exceptions: raise_server_exceptions
When the application raises an exception that no handler turns into a response, a real server answers 500 Internal Server Error. TestClient by
default re-raises that exception inside the test, so a bug shows up as a traceback instead of an opaque status code. Use raise_server_exceptions=False
when the 500 response itself is what you want to test (for example a custom handler for Exception, see
unhandled errors).
import pytest
from fastapi.testclient import TestClient
from app.main import app
def test_unhandled_exception_is_raised_in_the_test() -> None:
client = TestClient(app)
with pytest.raises(RuntimeError, match="unexpected failure"):
client.get("/boom")
def test_unhandled_exception_as_a_500_response() -> None:
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/boom")
assert response.status_code == 500
assert response.text == "Internal Server Error"
Source: Starlette: Test Client
Overriding Dependencies with app.dependency_overrides
Dependency injection is what makes a FastAPI application testable without patching. app.dependency_overrides is a plain dict: the key is the original dependency
callable (the same object that appears in Depends(…)), the value is a replacement callable. FastAPI calls the replacement wherever the
original would have been called, for every route and every sub-dependency that declares it; the original, and the dependencies it would have used, are
not called at all. The replacement may itself declare parameters (query parameters, other dependencies), which FastAPI resolves as usual. The mechanism is explained in
Dependency Injection; here it is applied to the external ISBN client of the application, which is slow,
rate limited and not something a unit test should call.
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from app.isbn import IsbnLookup, get_isbn_lookup
from app.main import app
class FakeIsbnLookup:
def __init__(self, titles: dict[str, str]) -> None:
self.titles = titles
self.calls: list[str] = []
def title_for(self, isbn: str) -> str | None:
self.calls.append(isbn)
return self.titles.get(isbn)
@pytest.fixture(name="isbn")
def isbn_fixture(client: TestClient) -> FakeIsbnLookup:
fake = FakeIsbnLookup({"9780441013593": "Dune"})
app.dependency_overrides[get_isbn_lookup] = lambda: fake
return fake # the client fixture clears every override at teardown
def test_known_isbn(client: TestClient, isbn: FakeIsbnLookup) -> None:
response = client.get("/isbn/9780441013593")
assert response.json() == {"isbn": "9780441013593", "title": "Dune"}
assert isbn.calls == ["9780441013593"]
def test_unknown_isbn(client: TestClient, isbn: FakeIsbnLookup) -> None:
assert client.get("/isbn/0000000000").status_code == 404
def test_same_thing_with_mock_patch(client: TestClient) -> None:
# patch() replaces an attribute of the class: it works, but it ties the test to
# where the code looks the name up. The override ties it to the public seam instead.
with patch.object(IsbnLookup, "title_for", return_value="Kindred"):
response = client.get("/isbn/123")
assert response.json()["title"] == "Kindred"
What the file demonstrates:
-
A hand-written fake (
FakeIsbnLookup) replaces the dependency through a fixture. It is a small class with the same method as the real one, it records the calls it received, and the tests assert on behaviour (the response) and on the interaction (isbn.calls). Theclientfixture clears every override at teardown, so this fixture does not need its own cleanup. -
unittest.mock.patch.objectachieves the same result by replacing an attribute of the real class. It works, and it is the right tool for a function that is called directly and is not a dependency. For things reached throughDepends, the override is better: it replaces the object at the seam the application deliberately offers, it does not depend on the module in which a name is imported and it cannot leak to other tests when awith patch(…)block is forgotten. -
Fakes over mocks. A fake with real behaviour (an in-memory repository, a dictionary of titles) keeps tests short and keeps them passing when the code under test is refactored; a heavily configured
Mockasserts how the code does things rather than what it achieves.
Rules for overrides, each of which has caused a failing test somewhere:
-
The key must be the same function object used in
Depends. An alias created withAnnotated[Session, Depends(get_session)]is fine, because it wraps the same function; a different function that looks the same is not. -
An override replaces the whole dependency, including any authorization it performs. Test the real checker separately, as Testing Auth-Protected Routes does.
-
Overriding a yield dependency with a plain function skips its setup and its teardown. If the real dependency commits or closes something that the test relies on, replicate that in the override.
-
Clear the dictionary after each test (the
clientfixture does it). A leaked override makes later tests pass or fail depending on their order. -
The dictionary belongs to one application object. A mounted sub-application has its own, and overriding the parent changes nothing inside it:
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
def current_user() -> str:
raise RuntimeError("would call the real identity provider")
main = FastAPI()
admin = FastAPI()
@admin.get("/whoami")
def whoami(user: Annotated[str, Depends(current_user)]) -> str:
return user
main.mount("/admin", admin)
def test_overrides_are_per_application() -> None:
main.dependency_overrides[current_user] = lambda: "grace" # wrong application
client = TestClient(main)
try:
client.get("/admin/whoami")
except RuntimeError as error:
assert "real identity provider" in str(error) # the override was not used
admin.dependency_overrides[current_user] = lambda: "grace" # the mounted app owns it
assert client.get("/admin/whoami").json() == "grace"
main.dependency_overrides.clear()
admin.dependency_overrides.clear()
The sub-application mechanism is on Sub-applications, WSGI & GraphQL.
Settings are the other common override. Because the client fixture overrides get_settings with a hermetic Settings object, every request sees the same test configuration, and the
cached accessor never reads the real environment. How the dependency is built, when to call cache_clear() and why Settings(_env_file=None, …) is used are on
Settings, Lifespan & Background Tasks. For code that reads os.environ directly, pytest’s monkeypatch.setenv is the tool; see
Unit Testing with pytest & Mocking.
Testing Auth-Protected Routes
An authenticated route needs a user. There are three ways to provide one, and a healthy suite uses all three in different places.
| Approach | How | Use it for |
|---|---|---|
Log in for real |
|
A few tests of the login flow itself |
Mint a token |
Call the application’s own |
Most tests of protected routes: realistic, fast, independent of passwords |
Override the user dependency |
|
Tests where the identity is irrelevant, or where you need a specific user without any token |
The security behaviour itself is tested separately and exhaustively, because a mistake there is the most expensive one:
import jwt
from fastapi.testclient import TestClient
from app.config import Settings
from app.security import create_access_token, get_current_user
def test_login_returns_a_token_that_works(client: TestClient, settings: Settings) -> None:
response = client.post("/token", data={"username": "ada", "password": "ada-test-password"})
assert response.status_code == 200
token = response.json()["access_token"]
payload = jwt.decode(token, settings.jwt_secret.get_secret_value(), algorithms=["HS256"])
assert payload["sub"] == "ada"
created = client.post(
"/authors", json={"name": "Octavia E. Butler"},
headers={"Authorization": f"Bearer {token}"},
)
assert created.status_code == 201
def test_wrong_password_is_401(client: TestClient) -> None:
response = client.post("/token", data={"username": "ada", "password": "wrong"})
assert response.status_code == 401
assert response.headers["www-authenticate"] == "Bearer"
def test_missing_token_is_401(client: TestClient) -> None:
response = client.post("/authors", json={"name": "Anonymous"})
assert response.status_code == 401
assert response.json() == {"detail": "Not authenticated"}
def test_expired_token_is_401(client: TestClient, settings: Settings) -> None:
expired = create_access_token("ada", settings.model_copy(update={"token_minutes": -1}))
response = client.post(
"/authors", json={"name": "Late"}, headers={"Authorization": f"Bearer {expired}"}
)
assert response.status_code == 401
assert response.json() == {"detail": "Invalid or expired token"}
def test_token_signed_with_another_key_is_401(client: TestClient) -> None:
forged = jwt.encode({"sub": "ada"}, "some-other-key-0123456789-0123456789", algorithm="HS256")
response = client.get("/books", headers={"Authorization": f"Bearer {forged}"})
assert response.status_code == 200 # /books is public: the header is simply ignored
response = client.post(
"/authors", json={"name": "Mallory"}, headers={"Authorization": f"Bearer {forged}"}
)
assert response.status_code == 401
def test_override_the_user_instead_of_logging_in(client: TestClient) -> None:
from app.main import app
app.dependency_overrides[get_current_user] = lambda: "grace"
response = client.post("/authors", json={"name": "Anyone"}) # no Authorization header
assert response.status_code == 201
What is covered, and what the status codes mean:
-
Login. The password form is sent with
data=, notjson=, becauseOAuth2PasswordRequestFormreads form fields. The test decodes the token with the settings' secret and checks the subject, then uses the token on a protected route. -
Wrong password.
401withWWW-Authenticate: Bearer, as the OAuth2 specification and the HTTP semantics require (see OAuth2 Password Flow & JWT). -
No credentials.
OAuth2PasswordBeareranswers401withNot authenticated. Older FastAPI versions answered403here; since 0.122.0 (2025-11-24) the security classes return401. A test that still expects403for missing credentials is one of the first to break after an upgrade; the official page for the previous behaviour is Use Old 403 Authentication Error Status Codes. If your application keeps the old status through a subclass, every test that builds or overrides the scheme must use the same subclass (see Security Fundamentals). -
Expired token. The test builds a token whose lifetime is negative with
settings.model_copy(update={"token_minutes": -1}): no sleeping and no mocked clock needed. -
Wrongly signed token. The test builds a token with a different key and checks that the protected route rejects it while a public route ignores the header.
-
Overridden user. No
Authorizationheader at all, yet the request is authenticated asgrace.
Scopes and roles follow the same pattern: mint tokens with the scopes under test, or override the dependency that checks them, and test the checker once on its own. The scope tests are in OAuth2 Scopes & Authorization. Argon2 verification is deliberately slow, so only the login tests pay for it; every other test uses a minted token.
Testing the Lifespan
Startup and shutdown code lives in the lifespan context manager of the application (see
Settings, Lifespan & Background Tasks). TestClient runs it only when the client is used as a context manager: the code before the yield runs when the block is entered
and the code after it when the block is left. A client that is merely created does not run it.
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.main import app
events: list[str] = []
@asynccontextmanager
async def recording_lifespan(app: FastAPI):
events.append("startup")
yield
events.append("shutdown")
probe = FastAPI(lifespan=recording_lifespan)
@probe.get("/ping")
def ping() -> str:
return "pong"
def test_lifespan_runs_only_inside_with() -> None:
events.clear()
client = TestClient(probe) # constructed, never entered
assert client.get("/ping").json() == "pong"
assert events == [] # the request worked, the lifespan did not run
with TestClient(probe) as entered:
assert events == ["startup"]
assert entered.get("/ping").json() == "pong"
assert events == ["startup", "shutdown"]
def test_the_real_lifespan_warms_the_cache() -> None:
with TestClient(app) as client:
assert client.get("/genres").json() == ["fiction", "history", "science"]
assert app.state.genres == [] # leaving the block ran the shutdown code
The first test builds a tiny probe application whose lifespan records what happens. The request on the plain client succeeded, but events stayed empty: the lifespan never ran. Entering the context manager runs the startup code, and
leaving it runs the shutdown code. The second test checks the real application: the cache is warmed inside the block and emptied when it ends.
Consequences for a suite:
-
Use
with TestClient(app) as clientin a fixture (asconftest.pydoes) whenever the application has a lifespan, or the endpoints that depend on lifespan state fail withAttributeErrororKeyError. A client withoutwithis fine for applications and tests that do not need it, and is what the first example on this page used. -
The deprecated
@app.on_event("startup")and("shutdown")handlers behave the same way: they also run only insidewith TestClient(app). New code should uselifespan; when a lifespan is given,on_eventhandlers are ignored. -
A lifespan that talks to the network or opens a real pool must be made testable: read its inputs from settings and use an in-memory fake or a container in the test environment, or the whole suite fails when the dependency is down. Overrides of
dependency_overridesdo not apply inside the lifespan, because it is not a dependency. -
A mounted sub-application’s lifespan is not run by the parent’s lifespan; test it with its own client.
-
Because the context manager is entered once per fixture, a function-scoped
clientstarts and stops the lifespan for every test. That is exactly right for isolation and cheap unless the lifespan loads something large; for that case use a wider scope and reset the state in between.
Testing WebSockets
client.websocket_connect(url) returns a context manager for a test session. Inside it, send_text, send_bytes and send_json go to the server, and receive_text,
receive_bytes and receive_json read what the server sent; leaving the block closes the connection. A rejected handshake or a server-side close raises WebSocketDisconnect carrying the close code, and params= is not supported (put the query string in the URL).
The details and the failure modes are on WebSockets; the minimum is a single connection:
from fastapi.testclient import TestClient
from app.main import app
def test_echo() -> None:
client = TestClient(app)
with client.websocket_connect("/ws/books/7") as websocket:
websocket.send_text("hello")
assert websocket.receive_json() == {"book_id": 7, "echo": "hello"}
Source: FastAPI: Testing WebSockets
A feature that involves several connections (a feed that broadcasts a new review to every open page) needs more care. Open the clients with with TestClient(app) as client:, as the client fixture does, so that all of them
share one event loop and one portal. The test below opens two sockets on the same book, posts a review through the HTTP API and checks that both sockets receive the event:
from fastapi.testclient import TestClient
from app.models import Author, Book
def test_every_open_socket_gets_the_new_review(client: TestClient, session, auth) -> None:
author = Author(name="N. K. Jemisin")
session.add(author)
session.commit()
book = Book(title="The Fifth Season", author_id=author.id)
session.add(book)
session.commit()
# `client` was created with `with TestClient(app)`, so both connections share one
# event loop and one portal. Without that, every websocket_connect() would start its own.
with client.websocket_connect(f"/ws/books/{book.id}/reviews") as first:
with client.websocket_connect(f"/ws/books/{book.id}/reviews") as second:
response = client.post(
f"/books/{book.id}/reviews", json={"rating": 5, "text": "Superb"}, headers=auth
)
assert response.status_code == 201
assert first.receive_json() == {"rating": 5, "reviewer": "ada"}
assert second.receive_json() == {"rating": 5, "reviewer": "ada"}
Source: FastAPI: Testing WebSockets
Without with TestClient(app), each websocket_connect runs in its own thread with its own event loop. Code that shares loop-bound objects such as locks, queues or futures between connections is then unsafe: the chat manager
on the WebSockets page hung until its test used the context-manager form. Two small variants written for this page (the rooms above, and a queue shared between two sockets) happened to pass without it, which is the danger: they pass by luck. Treat the
context-manager form as required for any test with more than one connection. Note also that the new review reaches the sockets through a background task of the HTTP request, which TestClient completes before it returns the response (Testing Background Tasks).
Testing Background Tasks
BackgroundTasks run after the response has been sent. TestClient waits for them: the call returns once the application has finished the whole ASGI exchange, background tasks included, so a test can assert on their effect right after the request:
from fastapi.testclient import TestClient
from app.main import audit_log
from app.models import Author
def test_background_task_has_run_when_the_response_returns(client: TestClient, session, auth) -> None:
audit_log.clear()
author = Author(name="Terry Pratchett")
session.add(author)
session.commit()
response = client.post("/books", json={"title": "Small Gods", "author_id": author.id}, headers=auth)
assert response.status_code == 201
assert audit_log == [f"ada created book {response.json()['id']}"]
Source: FastAPI: Background Tasks
The task here appends to a list standing for an audit log; in a real application it sends an e-mail or refreshes a cache, and you override that collaborator as in Overriding Dependencies with app.dependency_overrides. That a task ran is observable; that it ran after the response was sent is not, because the test client waits. Tasks that need retries, durability or other processes belong in a queue and are tested at the queue’s boundary (see Settings, Lifespan & Background Tasks).
Async Tests with anyio and httpx
TestClient is synchronous. That is its strength, but it creates its own event loop in a helper thread, so a test that has to await something itself cannot share that loop with the application. The typical case is a test that calls the application and then
checks the database through an async driver, or an async fixture holding a connection that must live on the same loop as the app. The official recipe is an async test function, the AnyIO pytest plugin and an async HTTP client that talks to the ASGI app directly:
import pytest
from httpx import ASGITransport, AsyncClient
from sqlmodel import Session, select
from app.main import app
from app.models import Author
@pytest.fixture
def anyio_backend() -> str:
return "asyncio" # run only on asyncio; add "trio" here if you support it
@pytest.mark.anyio
async def test_create_author_and_check_the_database(client, session: Session, auth) -> None:
# `client` (the sync fixture) is only used here for its dependency overrides.
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
response = await ac.post("/authors", json={"name": "Chinua Achebe"}, headers=auth)
assert response.status_code == 201
names = [author.name for author in session.exec(select(Author))]
assert names == ["Chinua Achebe"]
Source: FastAPI: Async Tests
The parts, in order:
-
@pytest.mark.anyiotells the AnyIO plugin (installed together withanyio, no extra package) to run this coroutine test on an event loop. It is the mechanism FastAPI’s documentation recommends; the pytest-asyncio plugin is an alternative with its own configuration (see pytest-asyncio); pick one of the two for a suite. -
anyio_backendis the fixture the plugin reads to choose the loop. By default every test runs once per available backend: asyncio, and also Trio when Trio is installed. Returning"asyncio"from your own fixture, as here, pins it. Running on Trio needs Trio installed and an application written with AnyIO primitives rather than asyncio ones. See AnyIO: Testing with AnyIO. -
AsyncClient(transport=ASGITransport(app=app), base_url="http://test")is HTTPX’s async client with a transport that calls the ASGI app in the current event loop.base_urlis required but arbitrary. This is the async equivalent ofTestClient, and it is whatTestClientis built on. -
await ac.post(…)is a real await on the test’s own loop, so the test can thenawaitanything else, here a check of the rows. The check uses the synchronoussessionfixture to keep the example short; with an async engine you wouldawaitthe query on the same loop.
The same overrides apply, because they live on the application. The sync client fixture is requested above only to install them; an async test that needs the overrides can also set them in its own fixture.
Two limits of the async client matter in practice:
-
It does not run the lifespan. Neither
AsyncClientnorASGITransporttriggers startup and shutdown. The official page recommends theasgi-lifespanpackage (LifespanManager) for that. The application’s own router can do it too, as the second example below shows:
import pytest
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
@pytest.mark.anyio
async def test_asyncclient_does_not_run_the_lifespan() -> None:
app.state.genres = None
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
assert app.state.genres is None # no startup happened
@pytest.mark.anyio
async def test_lifespan_manager_runs_it() -> None:
async with LifespanManager(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
response = await ac.get("/genres")
assert response.json() == ["fiction", "history", "science"]
assert app.state.genres == [] # shutdown ran when the manager exited
Source: FastAPI: Async Tests
Both tests passed. The first shows that the lifespan did not start; the second runs it with LifespanManager, and when the manager exits the shutdown code has run. The router alternative is async with app.router.lifespan_context(app):; it started and stopped the lifespan in a test of its own (see The httpx and httpx2 Warning in Starlette 1.7 for that test, which uses the httpx2 package).
-
It does not mix with a
TestClienton the same loop. Do not callTestClientfrom inside anasync deftest: it blocks the loop it is running on. Use one style per test. ARuntimeError: Task attached to a different loopusually means that an object with a loop (a driver client, a lock) was created at import time or in a sync fixture; create it inside the async test or an async fixture.
TestClient |
AsyncClient with ASGITransport |
|
|---|---|---|
Test function |
|
|
Event loop |
Its own, in a helper thread |
The test’s own |
Lifespan |
|
Not run; use |
WebSockets |
Supported |
Not supported by the client |
Await things in the test |
No |
Yes |
Typical use |
Almost every API test |
Tests that must share a loop with async resources |
Start with TestClient, and reach for the async client only when the test itself has something to await. The reasons are in Async & Concurrency.
Testing Against a Database
The session fixture above is the whole database setup of the suite: an in-memory SQLite engine, the tables created from SQLModel.metadata, one session shared with the request code through the override. It has three consequences, each demonstrated below:
-
the test can read the rows the API wrote with the same session;
-
every test starts from an empty database, because the engine is created per test;
-
SQLite is not PostgreSQL, so some production rules need to be switched on explicitly.
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import event
from sqlalchemy.exc import IntegrityError
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine, select
from app.models import Author, Book, Review
def test_the_api_wrote_the_rows_we_expect(client: TestClient, session: Session, auth) -> None:
client.post("/authors", json={"name": "Italo Calvino"}, headers=auth)
author = session.exec(select(Author)).one() # same session, same in-memory database
client.post("/books", json={"title": "Invisible Cities", "author_id": author.id}, headers=auth)
books = session.exec(select(Book)).all()
assert [book.title for book in books] == ["Invisible Cities"]
def test_every_test_starts_from_an_empty_database(session: Session) -> None:
assert session.exec(select(Book)).all() == [] # nothing left over from the test above
def test_sqlite_ignores_foreign_keys_by_default(session: Session) -> None:
session.add(Review(book_id=999, rating=3, reviewer="ada")) # book 999 does not exist
session.commit() # accepted: SQLite does not enforce foreign keys unless asked to
def test_enforcing_foreign_keys_like_production_does() -> None:
engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
poolclass=StaticPool)
@event.listens_for(engine, "connect")
def enforce_foreign_keys(dbapi_connection, _record) -> None:
dbapi_connection.execute("PRAGMA foreign_keys=ON")
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
session.add(Review(book_id=999, rating=3, reviewer="ada"))
with pytest.raises(IntegrityError):
session.commit()
engine.dispose()
Source: FastAPI: Testing a Database
The third test is the instructive one. SQLite does not enforce foreign keys unless PRAGMA foreign_keys=ON is issued on the connection, so the first insert of a review for a book that does not exist is accepted, while PostgreSQL would reject it with an integrity error. The fourth test turns enforcement on with an engine connect event and gets the
IntegrityError that production would raise. If your application relies on a constraint, make the test engine enforce it, or the suite certifies a rule that the in-memory database never checks.
Where an in-memory SQLite database stops being a faithful stand-in:
| Difference | Consequence for tests |
|---|---|
Foreign keys off by default |
Enable the pragma, as above |
Dynamic typing (a |
Type and length violations are not caught; rely on Pydantic validation and on a real engine in CI |
No server-side types such as arrays, JSONB, enums with checks, schemas |
Tests of queries using them need the real engine |
Different SQL dialect details ( |
A query can pass on SQLite and fail in production, or the opposite |
Single connection with |
No test of concurrent transactions, deadlocks or connection-pool limits |
Schema created by |
Migrations are not tested; run Alembic against a real database in CI |
A sound arrangement is two layers: most tests use the in-memory engine for speed, and a smaller set of integration tests runs the same suite, or its repository tests, against the real engine in a throwaway container (a PostgreSQL started in the CI job or by the developer), selected with a marker or an environment variable. The session dependency and the repository tests of SQL Databases with SQLModel are written so that only the engine URL changes. Stores such as Redis or MongoDB follow the same rule; the fakes and real servers used in NoSQL & Other Data Stores show where a fake is enough.
The Test Pyramid and Per-Layer Tests
A test suite is a portfolio: many fast tests that are cheap to run and to diagnose, fewer slow tests that give broad confidence. The classic picture is a pyramid, and for a FastAPI service it maps onto the layers of Project Structure & Layered Architecture:
real server, real database, real network
smoke test after deployment, Schemathesis, Locust"] API["API / integration: some tests
TestClient or AsyncClient, overrides for the edges,
in-memory or containerised database"] UNIT["Unit: many tests
pure functions, services with fakes, validators, no FastAPI"] E2E --- API --- UNIT classDef few fill:#fde8e8,stroke:#c0392b,color:#222; classDef some fill:#fff4d6,stroke:#b9770e,color:#222; classDef many fill:#e3f4e1,stroke:#2e7d32,color:#222; class E2E few; class API some; class UNIT many;
| Layer | What it checks | How | Speed |
|---|---|---|---|
Unit |
A function or class in isolation: business rules, validators, formatting |
Call it directly; a fake instead of a repository; |
Microseconds |
Repository / data |
Queries and mappings against a real engine |
The |
Milliseconds |
API (the bulk of FastAPI tests) |
Routing, validation, serialization, status codes, dependencies, auth |
|
Milliseconds |
End to end |
The deployed system as a user sees it, with real infrastructure |
A real server and HTTP client, a few critical journeys |
Seconds |
Unit tests need no FastAPI at all. The business rule of the application, the rating summary, is a plain function, and the interesting inputs are listed with parametrize:
import pytest
from app.service import rating_summary
@pytest.mark.parametrize(
("ratings", "expected"),
[
([], {"count": 0, "average": None}),
([5], {"count": 1, "average": 5.0}),
([5, 4, 4], {"count": 3, "average": 4.33}),
],
)
def test_rating_summary(ratings: list[int], expected: dict) -> None:
assert rating_summary(ratings) == expected
Source: pytest: Parametrizing Tests
Guidelines for the middle of the pyramid, where FastAPI tests live:
-
Keep logic out of path functions. A path function that parses, calls a service and shapes a response is easy to test through HTTP; one that contains a hundred lines of rules is not. Move the rules to a service and test them directly.
-
Test the HTTP contract once per route, not once per rule. A few requests per path operation (success, not found, invalid input, forbidden) are enough at the API layer; the many rule variations belong in unit tests.
-
Prefer fakes at the seams. A repository or client replaced by an in-memory fake through
dependency_overridesor constructor injection tests the service realistically; reserveunittest.mockfor edges that are hard to fake (time, randomness, a failing network call) and for asserting that something was not called. -
Call path functions directly only for pure logic. A path function that was extracted from its decorator and called with plain arguments skips routing, validation, dependencies and serialization, which are the things most likely to be wrong. The old habit of unit-testing path functions as plain functions is why many suites miss
422and401regressions. -
Make one failing test point at one cause. If a unit test and an API test fail together, fix the unit test first.
At the top, a handful of tests against a deployed environment confirm that the container, the proxy, TLS and the database wiring work; they are not written with TestClient. For generated cases and load, see Beyond Example-Based Tests.
The httpx and httpx2 Warning in Starlette 1.7
Run the first example of this page and pytest prints a warning (shown in The First Test: TestClient and pytest). Starlette’s TestClient was built on httpx from Starlette 0.21.0 (2022-09-26; FastAPI adopted it in 0.87.0, 2022-11-13, replacing requests). In the 1.x series it is built on httpx2, a successor package: Starlette 1.2.0
(2026-05-28) added support for it, and 1.3.0 (2026-06-11) put it in the full extra and adjusted the test client’s warnings. If only httpx is installed, the test client still works, but at import it emits
StarletteDeprecationWarning (a subclass of UserWarning, defined in starlette.exceptions):
.. [100%]
=============================== warnings summary ===============================
../../../venv/lib/python3.13/site-packages/fastapi/testclient.py:1
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
2 passed, 1 warning in 0.80s
The message appears once per test run, at the line that imports fastapi.testclient. It does not change how tests are written or what they assert, and the FastAPI tutorial’s uv add httpx instruction is still what the official pages say at the time of writing. You have three ways to deal with it, in this order of preference.
Install httpx2. This is the intended fix: the test client then uses it and the warning disappears. Nothing in the tests changes, as the same suite shows with -W error (any warning would fail the run), including the variant of the async test that uses the httpx2 client:
pip install httpx2
pytest -q -W error
.................................... [100%]
36 passed in 0.34s
That run uses a separate virtual environment containing httpx2 and httpx (the application itself uses httpx in app/isbn.py). Whether a suite uses httpx2, httpx or both only depends on what is installed: the test client prefers httpx2 and falls back to httpx; with neither it raises a RuntimeError telling you to install httpx2. This is the variant of the async test for that
environment, including the lifespan started with the router’s own context manager:
import pytest
from fastapi.testclient import TestClient
from httpx2 import ASGITransport, AsyncClient
from app.main import app
def test_sync_client_without_a_warning() -> None:
with TestClient(app) as client:
assert client.get("/genres").json() == ["fiction", "history", "science"]
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
@pytest.mark.anyio
async def test_async_client_from_httpx2() -> None:
async with app.router.lifespan_context(app): # starts and stops the lifespan, no extra package
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
response = await ac.get("/genres")
assert response.json() == ["fiction", "history", "science"]
Source: Starlette: Test Client
Filter the warning when you cannot change the dependencies yet. Name the message so that other warnings stay visible. The strict configuration below turns every other warning into an error, which keeps a suite honest about deprecations in its own code:
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
filterwarnings = [
"error", # any other warning fails the run
"ignore:Using `httpx` with `starlette.testclient` is deprecated:starlette.exceptions.StarletteDeprecationWarning",
]
Source: pytest: How to capture warnings
.. [100%]
2 passed in 0.78s
On the command line the same effect is pytest -W "ignore::starlette.exceptions.StarletteDeprecationWarning"; the suite of this page passed with it:
..................................... [100%]
37 passed in 0.31s
Do nothing and keep the warning is acceptable for a while, with one caveat: a suite configured with -W error or filterwarnings = ["error"] and no filter fails at collection, before any test runs:
==================================== ERRORS ====================================
_____________________ ERROR collecting tests/test_main.py ______________________
E ModuleNotFoundError: No module named 'httpx2'
E starlette.exceptions.StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
ERROR tests/test_main.py - starlette.exceptions.StarletteDeprecationWarning: ...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.73s
Using AsyncClient from httpx in async tests, as the official async page does, is unaffected by the warning: it concerns the test client only, and the async client is a separate import that works with either package. Pin the choice in your pyproject.toml test dependencies so that CI and developers agree.
Other TestClient Options: root_path, Address, Backend
TestClient(app, root_path="/api/v1") sets the ASGI root_path for every request, which simulates an application served under a prefix by a proxy (the situation of
Behind a Proxy). The important detail is what it does not do: it does not change the request URL. The test still calls /new, the response’s url stays http://testserver/new, the router matches the path
without the prefix, and the prefix is visible only where the application looks at root_path: in the scope, in generated URLs and in the OpenAPI servers. Do not write client.get("/api/v1/new") expecting the prefix to be required (Starlette also
tolerates the prefixed path, so a test written that way passes and proves nothing about the proxy setup).
def test_root_path_is_not_prepended_to_the_url() -> None:
client = TestClient(app, root_path="/api/v1")
response = client.get("/new")
assert response.url == "http://testserver/new" # the request URL has no prefix
body = response.json()
assert body["root_path"] == "/api/v1" # but the ASGI scope carries it ...
assert body["url_for"] == "http://testserver/api/v1/new" # ... so generated URLs include it
Source: Starlette: Test Client
The other constructor arguments are raise_server_exceptions (Unhandled Exceptions: raise_server_exceptions), base_url, cookies, headers, follow_redirects and client, shown above, and backend and backend_options: backend="trio" runs the application on Trio (the application must be AnyIO-compatible), and backend_options={"use_uvloop": True}
selects uvloop for asyncio. The test client also exposes ASGI extensions to tests: HTTP trailers through response.extensions["http.response.trailers"] and debug information through response.extensions["http.response.debug"], which is what the template context above is built on. They are covered on the Starlette page in the references.
Coverage with pytest-cov
Coverage tells you which lines (and, with branch coverage, which if outcomes) the tests never executed. It cannot tell you that the tests are good, but a red line in a security dependency is a precise to-do list. pytest-cov plugs the coverage tool into pytest. Configure the measured package and the report in pyproject.toml, then add --cov:
[tool.coverage.run]
source = ["app"] # measure the application package, not the tests
branch = true # also report branches never taken
[tool.coverage.report]
show_missing = true
fail_under = 90 # the run fails below this total percentage
pip install pytest-cov
pytest --cov --cov-report=term-missing
..................................... [100%]
================================ tests coverage ================================
______________ coverage: platform darwin, python 3.13.16-final-0 _______________
Name Stmts Miss Branch BrPart Cover Missing
-------------------------------------------------------------
app/__init__.py 0 0 0 0 100%
app/config.py 11 1 0 0 91% 17
app/db.py 14 5 2 0 56% 14-16, 20-21
app/isbn.py 9 2 0 0 78% 11-12
app/main.py 94 4 10 2 94% 67, 93, 104-105
app/models.py 19 0 0 0 100%
app/rooms.py 14 0 2 0 100%
app/security.py 20 0 0 0 100%
app/service.py 4 0 2 0 100%
-------------------------------------------------------------
TOTAL 185 12 16 2 92%
Required test coverage of 90.0% reached. Total coverage: 92.04%
37 passed in 0.44s
Reading the report:
-
MissandMissinglist the statements and line numbers the tests never ran. Inapp/main.pythe lines 67 and 93 are the failure branches that no test of this suite triggers (wrong credentials are covered, but an unknown author when creating a book and an unknown book when reviewing are not), and 104 and 105 are the rating endpoint, which no test requests.app/db.pyis at 56% andapp/isbn.pyat 78% because the realget_engine,get_sessionand the network call ofIsbnLookupare replaced by overrides in every test, which is the expected trade-off of overriding. One or two such files are fine when they are covered by an integration job that does not override them. -
BranchandBrPartcountif/elseoutcomes: a line can be executed while one of its two outcomes never is, which the plain line percentage hides. -
The code that the
TestClientruns in its helper thread is measured too (themain.pyfigures above include everything the requests executed), because coverage traces threads by default. -
A threshold enforces a floor, not quality.
fail_under(or--cov-fail-under=95on the command line) makes the run fail with a non-zero exit code, which is what a CI step needs:
pytest --cov --cov-fail-under=95
TOTAL 185 12 16 2 92%
FAIL Required test coverage of 95% not reached. Total coverage: 92.04%
37 passed in 0.43s
exit code: 1
Practical advice: measure branches, exclude only what is truly untestable (if name == "main":, if TYPE_CHECKING:; configure it under [tool.coverage.report] exclude_also), ratchet the threshold upwards as the suite grows, and do not write tests whose only purpose is to touch a line. Coverage of the OpenAPI
document and of generated cases is a different question, answered in Beyond Example-Based Tests.
Beyond Example-Based Tests
The tests above check examples you thought of. Two further techniques look for the ones you did not, and both reuse what is on this page:
-
Property-based testing. The OpenAPI document that FastAPI generates is a machine-readable contract. Schemathesis reads
/openapi.json, generates requests with Hypothesis and checks every response against the declared schema and status codes; the same application and the same fixtures serve it. See Testing the Schema, and Testing Against It for the schema-level checks, and Property-based & Load Testing for Hypothesis, Schemathesis with pytest and Locust for load. -
Security and load tests. Fuzzing authentication and authorization, rate limits and behaviour under load are run against a real server and are described on that page too; they complement, and do not replace, the
TestClientsuite.
Running the Suite in CI
A short, repeatable command is the contract between developers and the pipeline: install the test dependencies, run pytest --cov with the threshold, and publish the coverage report. Keep the environment close to production (the same Python minor version, the same pinned FastAPI,
Pydantic and Starlette versions; FastAPI is still a 0.x library, so pin it) and run the database integration tests, if any, in a service container. A matrix over Python 3.10 to 3.14 is cheap insurance when you support several versions. Treat warnings as errors in CI, with the single named filter from the previous section, so that
deprecations show up in a pull request instead of in an upgrade.
Pitfalls
-
Forgetting
with TestClient(app). The lifespan does not run, state created at startup is missing and the failure looks like anAttributeErrorin the application. Use the context manager in the fixture. -
Leaking overrides.
app.dependency_overridesis global to the app object. Clear it in fixture teardown; otherwise the next test runs with the previous test’s fake. -
Overriding the wrong application or the wrong key. The key is the function object in
Depends(…); a mounted sub-application has its own dictionary. -
An in-memory SQLite without
StaticPoolorcheck_same_thread=False. Every connection sees an empty database, or the connection is used from another thread and SQLite raises. Both show up asno such tableor a thread error. -
Trusting SQLite for rules it does not enforce. Foreign keys, strict types and dialect details need the production engine somewhere in the pipeline.
-
Reading the real environment. A test that passes only because a developer’s
.envexists. BuildSettings(_env_file=None, …)with explicit values and random secrets. -
Hard-coded secrets in fixtures and examples. Generate throwaway keys (
secrets.token_hex(32)); never reuse a production key, and never commit one. -
Asserting on
msg. Pydantic’s message text changes between releases; assert ontypeandloc. -
Sleeping to wait for work.
TestClientwaits for background tasks, and time-dependent code (token expiry) is tested by constructing the input, as the expired-token test does with a negative lifetime. -
Calling
TestClientfrom anasync deftest. It blocks the test’s loop. UseAsyncClientwithASGITransportthere. -
Expecting the lifespan from
AsyncClient. It does not run; useLifespanManageror the router’slifespan_context. -
A single
TestClientfor several WebSocket connections withoutwith. Each connection gets its own loop; shared state can hang. -
Expecting
root_pathto prefix the URL. It changes the scope and generated URLs only. -
Expecting
403for missing credentials. Since 0.122.0 the security classes answer401. -
Letting an unhandled exception surprise the test. By default it is raised in the test; pass
raise_server_exceptions=Falseto assert on the500. -
Patching names instead of overriding dependencies.
mock.patchties the test to import paths; the override ties it to the seam. -
Relying on test order. Module-level state (a global list, the app’s
state, anlru_cache) survives between tests. Reset it in fixtures, as the lifespan test does withevents.clear(). -
Running tests with an unpinned FastAPI. A new 0.x release can change behaviour, such as the move to
401,strict_content_typefor JSON bodies or a different warning from the test client. Pin it and upgrade deliberately with the suite as the safety net. -
Chasing 100% coverage. A threshold is a floor. A test with no assertion raises the number without checking anything.
Where Older Guides Are Outdated, and What Changed Recently
Many guides and older books test FastAPI code by calling path functions directly, or use the requests-based test client, startup events and
the old status codes. The table lists the differences that most often make an old test suite fail or mislead.
| Older guides and code | Today |
|---|---|
Calling path functions as plain Python functions in tests |
|
|
An |
Plain |
Works with a |
|
Handlers and |
Patching with |
|
|
|
|
|
Pydantic v1 error payloads ( |
Pydantic v2 payloads ( |
|
|
|
|
Async tests with |
|
Testing against a file-based SQLite database or a shared database |
In-memory SQLite with |
Hard-coded keys and credentials in test settings |
Random keys per run, required secrets without defaults |
The full release history is on What’s Changed & Migration; neighbouring topics are on Dependency Injection, Settings, Lifespan & Background Tasks, WebSockets, SQL Databases with SQLModel and Deployment. The collected sources of the section are in Bibliography, and the one-page summary is the Cheat Sheet.