TestClient exercises FastAPI routes through HTTP. Expensive or external dependencies can be replaced through app.dependency_overrides, provided cleanup restores global application state.
from fastapi.testclient import TestClient
from app.main import app, current_user
def fake_user() -> dict[str, object]:
return {"id": 7, "admin": False}
def test_profile() -> None:
app.dependency_overrides[current_user] = fake_user
try:
with TestClient(app) as client:
response = client.get("/profile")
assert response.status_code == 200
assert response.json()["id"] == 7
finally:
app.dependency_overrides.clear()
The context manager runs lifespan events. In larger suites, move override and cleanup into a fixture. Assert status, schema, and meaningful effects instead of only internal calls.
Use an isolated database, disposable transaction, or fake repository according to test level. Review FastAPI, pytest fixtures, and pytest-asyncio.
The official FastAPI testing documentation, accessed July 22, 2026, covers TestClient and overrides. Keep unit tests fast and a smaller integration layer connected to real infrastructure.
What a FastAPI route test should prove
A useful route test verifies the public contract rather than reproducing the endpoint implementation. Send the same method, path, headers, query parameters, and JSON body that a client would send. Then inspect the status code, response headers, and decoded body. For a creation endpoint, also verify the observable persistence effect. For an authorization rule, test an anonymous request, an authenticated request without permission, and an authorized request.
Avoid asserting every field when only two fields belong to the behavior under test. A complete literal comparison becomes noisy whenever the schema gains an unrelated optional field. Conversely, checking only status_code == 200 can miss a broken payload. Select assertions that describe the contract: identifiers have the expected value, secrets are absent, validation errors point to the correct input, and pagination metadata is coherent.
FastAPI validates input before calling the endpoint. Include malformed JSON, missing required values, boundary values, and values with the wrong type. Do not hard-code the complete validation response unless its exact format is part of your API contract. Usually it is safer to find the relevant item in response.json()["detail"] and assert its location and error category.
Build reusable pytest fixtures
Create the application and client in fixtures when setup is shared. A fixture should own everything it creates and release it after yield. This rule matters because dependency_overrides is a mutable dictionary on the application object.
import pytest
from fastapi.testclient import TestClient
from app.main import create_app, get_repository
@pytest.fixture
def fake_repository():
return InMemoryUserRepository()
@pytest.fixture
def client(fake_repository):
app = create_app()
app.dependency_overrides[get_repository] = lambda: fake_repository
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
An application factory gives each test a fresh app and reduces accidental coupling. If the project exports one global app, save the previous override value and restore it instead of clearing overrides installed by another fixture. Keep fixture scope at function until measurements justify broader state. Session-scoped mutable fakes often make results depend on execution order.
Use the with TestClient(app) form when the application has a lifespan handler. Entering the context starts resources such as connection pools; leaving it runs shutdown. Instantiating a client without the context is acceptable only when the behavior does not rely on lifespan events.
Override dependencies at the right boundary
Override the callable passed to Depends, not a different helper that happens to be called inside it. The dictionary key is the original function object. The replacement may be synchronous or asynchronous as appropriate and should use a compatible return value.
Authentication is a good boundary. Return a small domain user with explicit permissions, then test token decoding separately. A repository or service dependency is another useful boundary because it avoids network and database work while retaining request parsing, dependency injection, endpoint logic, and serialization.
Do not override everything. If validation, serialization, or exception mapping is the subject, keep those components real. A test in which every collaborator is mocked may pass while the assembled route is unusable. Reserve direct function tests for complex domain rules and route tests for HTTP integration.
Test exceptions, headers, and security
For known failures, assert the intended status and safe public message. A missing record might produce 404; a duplicate resource might produce 409. Unexpected internal details, SQL statements, tokens, and stack traces must not appear. If the API installs custom exception handlers, exercise them through a route.
Authentication tests should include an absent header, an invalid scheme, an expired credential, and insufficient scope where applicable. Do not put real credentials in source code or captured output. Construct synthetic tokens with test-only keys, or override the verified identity dependency.
For cookies, redirects, file downloads, and streaming responses, inspect the corresponding HTTP behavior. TestClient follows redirects by default, which can hide the original 307 or 302; disable redirect following when the redirect itself is the contract.
Async tests with HTTPX
TestClient is synchronous even when the route uses async def. It is convenient for most endpoint tests. Use an asynchronous test when the test itself must await an async repository, queue, or database session. FastAPI documents HTTPX with ASGITransport for that case.
import pytest
from httpx import ASGITransport, AsyncClient
@pytest.mark.anyio
async def test_health(app) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
) as client:
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
The transport sends requests directly to the ASGI application and does not open a real port. Lifespan management may require an explicit fixture depending on the HTTPX setup. Keep the test runner plugin and event-loop policy consistent.
Database isolation and test levels
Choose database realism deliberately. An in-memory repository is fast, but it cannot reveal SQL syntax, constraints, transaction behavior, or driver differences. SQLite is not a faithful substitute for every PostgreSQL feature. Keep a smaller integration suite against the database engine used in production, usually through a disposable database or container.
Rollback-based fixtures are fast, but code that opens independent connections or commits its own transaction can escape the outer rollback. Confirm the actual connection and transaction boundaries. Unique schemas or recreated databases provide stronger isolation at a higher setup cost.
Organize the suite into many focused unit tests, route contract tests with controlled dependencies, and fewer infrastructure integration tests. Add end-to-end tests only for critical journeys. This distribution gives quick feedback without pretending that fakes prove database compatibility.
Keep the suite deterministic
Freeze time through a clock dependency instead of patching many library calls. Generate stable identifiers or assert their format rather than a random exact value. Disable outbound network access by default so an omitted override fails immediately. For background tasks, verify the recorded intent without contacting the real provider.
Parallel execution exposes hidden shared state. Avoid module-level mutable repositories, reused rows, fixed temporary filenames, and global overrides. A failing test should be repeatable alone and in a random order.
Before merging, run the route suite with warnings visible. Treat deprecations from FastAPI, Starlette, Pydantic, and HTTPX as maintenance signals. Good API tests document behavior, catch incompatible dependency upgrades, and remain readable enough that a reviewer can understand the promised contract without opening the endpoint implementation.