# [FastAPI Testing](https://fastapi.tiangolo.com/tutorial/testing/) Best practices for testing FastAPI applications with pytest. ## Agent Quick Path Use this sequence before reading the full reference: 1. If test is pure route behavior, use `TestClient` and plain `def` tests. 2. If test must `await` other async work, use `AsyncClient` + `@pytest.mark.anyio`. 3. Prefer `app.dependency_overrides` over `mock.patch`. 4. Reset overrides after each test/fixture teardown. 5. For startup/shutdown logic, use `TestClient` as context manager or `LifespanManager` with async client. Decision rules: - Need DB contract verification: choose integration tests and override `get_db`/`get_session`. - Need pure business logic checks: keep tests HTTP-free (`unit`). - Need one critical path sanity check: one endpoint per `smoke` test. --- ## Tools | Tool | Purpose | |------|---------| | `fastapi.testclient.TestClient` | Synchronous HTTP test client (wraps HTTPX, built on Starlette) | | `httpx.AsyncClient` + `ASGITransport` | Async client for tests that `await` other async code | | `app.dependency_overrides` | Replace any `Depends()` dependency for the duration of a test | | `anyio` / `pytest-anyio` | Run async test functions with `@pytest.mark.anyio` | Install deps: `httpx`, `anyio` (or `pytest-anyio`). --- ## Synchronous Tests (Preferred Default) Use `TestClient` for route tests that don't need to `await` anything else. Test functions are plain `def` — no `async def`, no `await`. ```python from fastapi.testclient import TestClient from myapp.main import app client = TestClient(app) def test_read_item_returns_200(): response = client.get("/items/foo", headers={"X-Token": "secret"}) assert response.status_code == 200 assert response.json()["id"] == "foo" def test_read_item_bad_token_returns_400(): response = client.get("/items/foo", headers={"X-Token": "wrong"}) assert response.status_code == 400 ``` Rules: - One `TestClient` per test module is fine (stateless between calls). - Pass headers, query params, JSON body, or form data the same way as HTTPX/requests. - Do not pass Pydantic models directly; use `.model_dump()` or `jsonable_encoder`. --- ## Async Tests Use `AsyncClient` only when the test itself needs to `await` other coroutines (e.g. querying a real async DB after an API call to verify side effects). ```python import pytest from httpx import ASGITransport, AsyncClient from myapp.main import app @pytest.mark.anyio async def test_root_async(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: response = await ac.get("/") assert response.status_code == 200 ``` Rules: - Mark with `@pytest.mark.anyio`; register the `anyio` marker in `pyproject.toml`. - `AsyncClient` does **not** trigger lifespan events by default; use `asgi-lifespan`'s `LifespanManager` when startup/shutdown matters. - Instantiate objects that require an event loop (e.g. async DB clients) inside async functions, not at module level. --- ## Dependency Overrides (Preferred Over Mocking) `app.dependency_overrides` is the idiomatic FastAPI seam — use it instead of patching internals with `unittest.mock`. ```python from fastapi.testclient import TestClient from myapp.main import app from myapp.deps import get_current_user client = TestClient(app) def fake_user(): return {"id": 1, "name": "Test User"} def test_protected_route_with_fake_user(): app.dependency_overrides[get_current_user] = fake_user response = client.get("/me") app.dependency_overrides = {} # always reset after the test assert response.status_code == 200 assert response.json()["name"] == "Test User" ``` Or reset cleanly with `autouse=False` fixture teardown: ```python import pytest from myapp.main import app @pytest.fixture() def override_user(): app.dependency_overrides[get_current_user] = lambda: {"id": 1, "name": "Test User"} yield app.dependency_overrides = {} ``` Rules: - Override at the lowest-level dependency that owns the external boundary (e.g. `get_db`, `get_current_user`, `get_settings`). - Always reset `app.dependency_overrides` after each test or fixture teardown. - Prefer a real in-process fake (e.g. in-memory SQLite session) over a mock object. --- ## Database Testing The preferred pattern is a real SQLite (or test Postgres) session injected via dependency override, not a mock. ```python # tests/conftest.py import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from fastapi.testclient import TestClient from myapp.main import app from myapp.db.session import get_db from myapp.db.models import Base TEST_DB_URL = "sqlite:///./test.db" @pytest.fixture(scope="session") def engine(): e = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False}) Base.metadata.create_all(bind=e) yield e Base.metadata.drop_all(bind=e) @pytest.fixture() def db_session(engine): Session = sessionmaker(bind=engine) session = Session() yield session session.rollback() session.close() @pytest.fixture() def client(db_session): app.dependency_overrides[get_db] = lambda: db_session yield TestClient(app) app.dependency_overrides = {} ``` Rules: - Prefer `session`-scoped engine creation; `function`-scoped session with rollback per test. - Keep unit tests DB-free; use this pattern only in `integration`-marked tests. - For async SQLAlchemy, mirror the same pattern using `AsyncEngine` / `AsyncSession`. --- ## Lifespan and Startup Events `TestClient` triggers lifespan events (startup/shutdown) when used as a context manager: ```python def test_with_lifespan(): with TestClient(app) as client: response = client.get("/health") assert response.status_code == 200 ``` For `AsyncClient`, use `asgi-lifespan`: ```python from asgi_lifespan import LifespanManager @pytest.mark.anyio async def test_with_async_lifespan(): async with LifespanManager(app): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: response = await ac.get("/health") assert response.status_code == 200 ``` --- ## Marker Strategy for FastAPI Tests | Marker | When to use | |--------|------------| | `unit` | Pure service/utility logic with no HTTP or DB calls | | `integration` | `TestClient` + real DB session via dependency override | | `smoke` | One `TestClient` call per critical user path, no DB reset | | `external` | Tests that call real third-party APIs (skip in CI by default) | --- ## Quick Reference: Sending Data | What to send | Parameter | |---|---| | Path / query param | Part of the URL string | | JSON body | `json={"key": "value"}` | | Form data | `data={"field": "value"}` | | Headers | `headers={"X-Token": "..."}` | | Cookies | `cookies={"session": "..."}` | | File upload | `files={"file": ("name.txt", b"content", "text/plain")}` | --- ## Official Docs - [Testing tutorial](https://fastapi.tiangolo.com/tutorial/testing/) - [Async tests](https://fastapi.tiangolo.com/advanced/async-tests/) - [Testing dependencies with overrides](https://fastapi.tiangolo.com/advanced/testing-dependencies/) - [Testing a database (SQLModel)](https://fastapi.tiangolo.com/how-to/testing-database/) - [Testing lifespan events](https://fastapi.tiangolo.com/advanced/testing-events/) - [Testing WebSockets](https://fastapi.tiangolo.com/advanced/testing-websockets/) - [HTTPX docs](https://www.python-httpx.org/)