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

Table 1. Choosing the testing tool
You want to test Use Section

A path operation end to end (routing, validation, serialization, status codes)

TestClient in a plain def test

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

app.dependency_overrides

Overriding Dependencies with app.dependency_overrides

A route behind login

A minted token, a real login call or an overridden user dependency

Testing Auth-Protected Routes

Startup and shutdown code

with TestClient(app) as client

Testing the Lifespan

A WebSocket endpoint

client.websocket_connect(…​) inside with

Testing WebSockets

Code that must await things in the test itself

@pytest.mark.anyio plus an async client over ASGITransport

Async Tests with anyio and httpx

The data actually written to the database

An in-memory SQLite engine with StaticPool, shared with the test

Testing Against a Database

Pure business rules

Plain unit tests with parametrize, no FastAPI at all

The Test Pyramid and Per-Layer Tests

Which lines the tests never reach

pytest-cov

Coverage with pytest-cov

Hundreds of generated cases or load

Schemathesis, Hypothesis and Locust

Beyond Example-Based Tests

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 and tests/test_main.py of a minimal project
# 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"}
# 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"

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, not await. TestClient runs 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. An async def endpoint and a def endpoint (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 three with 422 and the Pydantic error type int_parsing.

What TestClient Does

flowchart LR T["pytest test
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.

The project used on this page
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:

pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]   # where pytest looks when run without arguments
pythonpath = ["."]      # the project root is importable: "from app.main import app"

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.

app/config.py: settings with a required secret and a cached accessor
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()
app/models.py: table models and request models
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
app/db.py: the engine and the session dependency
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)]
app/security.py: password hashing, token creation and the current-user dependency
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, app/isbn.py and app/rooms.py: business logic, an external client and WebSocket rooms
# 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()
app/main.py: the application
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 through Depends, so a test can replace each of them. The application never reads os.environ or opens a connection at import time (see the settings dependency).

  • The secret has no default. jwt_secret is a required SecretStr; 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_summary is 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.

tests/conftest.py
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}"}

What each fixture does and why it is written that way:

  • settings builds a Settings object from explicit values. _env_file=None stops 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 fresh secrets.token_hex(32) per test, so nothing real is involved and nothing needs to be hard-coded. Using Settings(…​) directly bypasses the lru_cache of get_settings.

  • session creates an in-memory SQLite database (sqlite://), creates the tables from the SQLModel metadata and yields a Session. Three details are not optional:

    • poolclass=StaticPool makes 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=False lets 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 the yield closes the connection explicitly, which also keeps pytest’s unraisable-exception warnings quiet.

  • client installs two overrides and builds the TestClient. It is entered with with so that the lifespan runs (Testing the Lifespan) and all requests share one event loop and portal. The lambda: session override means that the request code uses the same Session object as the test, which makes it trivial to prepare and inspect data. The overrides are removed with app.dependency_overrides.clear() after the yield, because app is a module-level object that outlives every test.

  • auth mints 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.

Table 2. Fixture scope and cost
Scope Use it for Notes

function (default)

The session, client and auth fixtures above

A fresh database per test: tests cannot influence each other, and the cost of an in-memory SQLite database is a few milliseconds

module

A read-only dataset shared by a file, a generated key pair

Mutation from one test is visible in the next one

session (pytest’s scope, not a database session)

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:

tests/test_books.py
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"

Points worth knowing:

  • Assert on status first, then on the body. A wrong status code makes the later .json() access fail with a confusing KeyError; asserting the status first turns that into a readable failure.

  • Assert on the stable parts of a validation error. Pydantic’s loc and type (string_too_short) are stable; the human-readable msg and the documentation URL change between releases. See Pydantic v2 Essentials for the error format and Status Codes & Error Handling for HTTPException and handlers.

  • Test the error responses you document. 404, 409, 422 and the authentication failures are part of the API contract just like 200.

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:

Table 3. What you can pass to a TestClient request
Argument Effect

params={"page": 2}

Query string; a list value repeats the key (tag=sf&tag=classic)

json=payload

JSON body with Content-Type: application/json

data=fields

URL-encoded form fields (Form() parameters, the OAuth2 password form)

files={"file": (name, bytes, content_type)}

Multipart upload for UploadFile and File() parameters; combine with data= for form fields

headers=mapping

Request headers for this call; client.headers or TestClient(app, headers=…​) for every call

cookies=mapping or TestClient(app, cookies=…​)

Cookies; cookies set by a response are kept in the client’s cookie jar for later calls

follow_redirects=False

Redirects are followed by default; turn it off to assert on 307/308 and the Location header

client.stream("GET", url)

Streaming responses, consumed with iter_lines(), iter_bytes() or iter_text()

TestClient(app, client=("203.0.113.7", 40000))

The client address the application sees in request.client; the default is ("testclient", 50000)

TestClient(app, base_url="https://api.bookshelf.example")

The host and scheme the application sees; the default is http://testserver

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.

tests/test_client_options.py
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"

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.

tests/test_streaming.py
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}]

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:

tests/test_templates.py
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

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).

tests/test_errors.py
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"

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.

tests/test_overrides.py
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). The client fixture clears every override at teardown, so this fixture does not need its own cleanup.

  • unittest.mock.patch.object achieves 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 through Depends, 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 a with 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 Mock asserts 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 with Annotated[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 client fixture 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:

tests/test_mounted.py
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.

Table 4. Three ways to authenticate in a test
Approach How Use it for

Log in for real

client.post("/token", data=form), then send the returned token

A few tests of the login flow itself

Mint a token

Call the application’s own create_access_token in a fixture (the auth fixture)

Most tests of protected routes: realistic, fast, independent of passwords

Override the user dependency

app.dependency_overrides[get_current_user] = lambda: "grace"

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:

tests/test_auth.py
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=, not json=, because OAuth2PasswordRequestForm reads 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. 401 with WWW-Authenticate: Bearer, as the OAuth2 specification and the HTTP semantics require (see OAuth2 Password Flow & JWT).

  • No credentials. OAuth2PasswordBearer answers 401 with Not authenticated. Older FastAPI versions answered 403 here; since 0.122.0 (2025-11-24) the security classes return 401. A test that still expects 403 for 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 Authorization header at all, yet the request is authenticated as grace.

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.

tests/test_lifespan.py
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 client in a fixture (as conftest.py does) whenever the application has a lifespan, or the endpoints that depend on lifespan state fail with AttributeError or KeyError. A client without with is 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 inside with TestClient(app). New code should use lifespan; when a lifespan is given, on_event handlers 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_overrides do 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 client starts 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:

tests/test_websockets.py
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"}

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:

tests/test_review_feed.py
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"}

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:

tests/test_background.py
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']}"]

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:

tests/test_async.py
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"]

The parts, in order:

  • @pytest.mark.anyio tells the AnyIO plugin (installed together with anyio, 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_backend is 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_url is required but arbitrary. This is the async equivalent of TestClient, and it is what TestClient is built on.

  • await ac.post(…​) is a real await on the test’s own loop, so the test can then await anything else, here a check of the rows. The check uses the synchronous session fixture to keep the example short; with an async engine you would await the 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 AsyncClient nor ASGITransport triggers startup and shutdown. The official page recommends the asgi-lifespan package (LifespanManager) for that. The application’s own router can do it too, as the second example below shows:

tests/test_async_lifespan.py
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

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 TestClient on the same loop. Do not call TestClient from inside an async def test: it blocks the loop it is running on. Use one style per test. A RuntimeError: Task attached to a different loop usually 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.

Table 5. TestClient or AsyncClient?
TestClient AsyncClient with ASGITransport

Test function

def

async def with @pytest.mark.anyio

Event loop

Its own, in a helper thread

The test’s own

Lifespan

with TestClient(app)

Not run; use LifespanManager or app.router.lifespan_context(app)

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.

tests/test_database.py
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()

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:

Table 6. SQLite in memory versus the production database
Difference Consequence for tests

Foreign keys off by default

Enable the pragma, as above

Dynamic typing (a TEXT value in an integer column is accepted)

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 (RETURNING, locking, ILIKE, collation, case sensitivity)

A query can pass on SQLite and fail in production, or the opposite

Single connection with StaticPool

No test of concurrent transactions, deadlocks or connection-pool limits

Schema created by create_all

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:

flowchart TB E2E["End to end: a few tests
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;
Table 7. What each layer tests
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; parametrize for the cases

Microseconds

Repository / data

Queries and mappings against a real engine

The session fixture, no HTTP

Milliseconds

API (the bulk of FastAPI tests)

Routing, validation, serialization, status codes, dependencies, auth

TestClient plus overrides

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:

tests/test_service.py
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

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_overrides or constructor injection tests the service realistically; reserve unittest.mock for 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 422 and 401 regressions.

  • 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:

variants/test_httpx2.py
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"]

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:

A pytest configuration that fails on every warning except this one
[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",
]
..                                                                       [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).

tests/test_client_options.py, the root_path test
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

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:

The coverage configuration appended to pyproject.toml
[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:

  • Miss and Missing list the statements and line numbers the tests never ran. In app/main.py the 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.py is at 56% and app/isbn.py at 78% because the real get_engine, get_session and the network call of IsbnLookup are 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.

  • Branch and BrPart count if/else outcomes: a line can be executed while one of its two outcomes never is, which the plain line percentage hides.

  • The code that the TestClient runs in its helper thread is measured too (the main.py figures 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=95 on 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 TestClient suite.

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 an AttributeError in the application. Use the context manager in the fixture.

  • Leaking overrides. app.dependency_overrides is 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 StaticPool or check_same_thread=False. Every connection sees an empty database, or the connection is used from another thread and SQLite raises. Both show up as no such table or 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 .env exists. Build Settings(_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 on type and loc.

  • Sleeping to wait for work. TestClient waits 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 TestClient from an async def test. It blocks the test’s loop. Use AsyncClient with ASGITransport there.

  • Expecting the lifespan from AsyncClient. It does not run; use LifespanManager or the router’s lifespan_context.

  • A single TestClient for several WebSocket connections without with. Each connection gets its own loop; shared state can hang.

  • Expecting root_path to prefix the URL. It changes the scope and generated URLs only.

  • Expecting 403 for missing credentials. Since 0.122.0 the security classes answer 401.

  • Letting an unhandled exception surprise the test. By default it is raised in the test; pass raise_server_exceptions=False to assert on the 500.

  • Patching names instead of overriding dependencies. mock.patch ties 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, an lru_cache) survives between tests. Reset it in fixtures, as the lifespan test does with events.clear().

  • Running tests with an unpinned FastAPI. A new 0.x release can change behaviour, such as the move to 401, strict_content_type for 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.

Table 8. Testing guidance over time
Older guides and code Today

Calling path functions as plain Python functions in tests

TestClient (or an ASGI async client) exercises routing, validation, dependencies and serialization, none of which a direct call to the function runs

requests-style test client (before Starlette 0.21.0 / FastAPI 0.87.0, November 2022)

An httpx-style client: allow_redirects became follow_redirects (and is on by default), and the client is built on httpx2 or httpx

Plain httpx with Starlette 1.x

Works with a StarletteDeprecationWarning (suggesting httpx2); install httpx2, supported since Starlette 1.2.0 (2026-05-28), or filter the warning

@app.on_event("startup") / ("shutdown") tested with TestClient

Handlers and lifespan run only inside with TestClient(app); lifespan is preferred since 0.93.0 (2023-03-07)

Patching with unittest.mock.patch for everything

app.dependency_overrides for dependencies, fakes for collaborators, mock only at hard-to-fake edges

TestClient(app) for every test, including WebSockets with several connections

with TestClient(app) shares one event loop and portal across connections

403 for missing credentials from OAuth2PasswordBearer, HTTPBearer and other security classes

401 since 0.122.0 (2025-11-24), with the old status available as a documented subclass pattern

Pydantic v1 error payloads (value_error.missing) and asserting on msg text

Pydantic v2 payloads (missing, string_too_short, int_parsing); assert on type and loc

.dict() and Config in test helpers and fixtures

model_dump() and model_config; Pydantic v1 is not supported (0.126.0, 2025-12-20)

TemplateResponse(name, context) with the request inside the context dictionary

TemplateResponse(request, name, context); response.template and response.context for assertions

Async tests with pytest-asyncio only, or AsyncClient(app=app)

@pytest.mark.anyio with AsyncClient(transport=ASGITransport(app=app), base_url=…​); the app= shortcut of the HTTP client was removed in current HTTPX

Testing against a file-based SQLite database or a shared database

In-memory SQLite with StaticPool per test, a container for the production engine

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.