237 lines
7.4 KiB
Markdown
237 lines
7.4 KiB
Markdown
# [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/)
|