FastAPI's dependency injection system makes it one of the most testable web frameworks in Python. Instead of patching functions at the call site with `unittest.mock`, you replace entire dependency chains at the app level before a test runs — cleanly and without touching any production code.
## Two testing clients
**`TestClient`** (synchronous, from `fastapi.testclient`):
```python
from fastapi.testclient import TestClient
from myapp.main import app
client = TestClient(app)
def test_get_items():
response = client.get('/items/')
assert response.status_code == 200
```
`TestClient` is built on httpx. It runs your ASGI app synchronously inside the test process — no real server is started. It's the right choice for most tests: simple, fast, and works without `pytest-asyncio`. Tests are plain `def` functions.
**`httpx.AsyncClient`** (asynchronous):
```python
# pytest.ini: asyncio_mode = auto
import httpx
import pytest
from myapp.main import app
@pytest.fixture
async def async_client():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
yield client
async def test_get_items(async_client):
response = await async_client.get('/items/')
assert response.status_code == 200
```
Use `AsyncClient` when the code under test is inherently async and you need to verify async behaviour — testing concurrent requests, async generators, or streaming responses. Requires `pytest-asyncio` with `asyncio_mode = auto` in `pytest.ini`.
## `app.dependency_overrides` — the core testing pattern
In FastAPI, routes declare their dependencies as function parameters:
```python
from fastapi import Depends
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.get('/items/')
def list_items(db: Session = Depends(get_db)):
return db.query(Item).all()
```
In tests, replace `get_db` with a fake function returning an in-memory store — without touching any production code:
```python
def fake_get_db():
yield {'items': []}
app.dependency_overrides[get_db] = fake_get_db
```
The override applies to every request made through the app while it is set. Always clean up when done — otherwise the override persists across test functions and pollutes subsequent tests:
```python
app.dependency_overrides.clear()
```
The standard pattern wraps setup and cleanup in a pytest fixture with `yield`:
```python
@pytest.fixture
def client_with_fake_db():
store = []
def override():
yield store
app.dependency_overrides[get_db] = override
yield TestClient(app), store
app.dependency_overrides.clear() # always runs, even on test failure
```
## When to use which client
| Scenario | Client |
|---|---|
| Testing request/response (status, JSON body) | `TestClient` |
| Testing async behaviour or concurrent requests | `AsyncClient` |
| Streaming responses, WebSockets | `AsyncClient` |
| Simplicity and speed | `TestClient` |
## Lifespan events in tests
FastAPI apps often have startup/shutdown events (initialising a connection pool, loading ML models). `TestClient` triggers these when used as a context manager:
```python
with TestClient(app) as client:
# startup has run
response = client.get('/items/')
# shutdown has run
```
For tests that verify startup-dependent state, always use the context manager form.
## `yield` vs `return` in dependency overrides
When the original dependency uses `yield` (common for resources that need teardown — database sessions, open files, network connections), your override replacement must **also use `yield`**. A plain `return` in the replacement silently skips teardown:
```python
# Original — yield dependency with teardown
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close() # ← this teardown runs after the request
# WRONG — override uses return; db.cleanup() will never run
def fake_db_wrong():
return FakeSession() # FastAPI gets the value; no teardown phase
# CORRECT — override uses yield; teardown runs after the route handler returns
def fake_db_correct():
db = FakeSession()
try:
yield db
finally:
db.cleanup() # ← guaranteed teardown
```
FastAPI resolves the override as a generator if it contains `yield`. If it's a plain function (no `yield`), FastAPI calls it, takes the return value, and skips any teardown logic. This is not an error — FastAPI does not warn you — so the bug is silent.
A `lambda` override only works for dependencies that have no teardown:
```python
# OK — get_store just returns a list, no teardown needed
app.dependency_overrides[get_store] = lambda: []
# WRONG — get_db has teardown; lambda silently skips it
app.dependency_overrides[get_db] = lambda: FakeSession() # db.close() never runs
```
## Overriding a SQLAlchemy database session
> **SQLAlchemy version note:** The pattern below uses `bind=` on `sessionmaker` and `Session` — these keyword arguments were deprecated in SQLAlchemy 1.4 and **removed in SQLAlchemy 2.0**. On SQLAlchemy 2.x, replace `sessionmaker(bind=engine)` with `sessionmaker(engine)` and `TestingSession(bind=connection)` with the `join_transaction_mode` pattern (see SQLAlchemy 2.x testing docs). The dependency-override approach and test isolation concept are identical across versions.
Overriding the database session dependency is the most common `dependency_overrides` pattern in real FastAPI projects. The canonical test setup creates an in-memory SQLite database for the test session, wraps each test in a rolled-back transaction, and injects the same session into both the test and the app:
```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.database import get_db, Base
engine = create_engine('sqlite://', connect_args={'check_same_thread': False})
TestingSession = sessionmaker(bind=engine)
@pytest.fixture(scope='session', autouse=True)
def create_tables():
Base.metadata.create_all(engine)
yield
Base.metadata.drop_all(engine)
@pytest.fixture
def db_session():
connection = engine.connect()
transaction = connection.begin()
session = TestingSession(bind=connection)
yield session
session.close()
transaction.rollback() # ← every INSERT/UPDATE/DELETE from the test is undone
connection.close()
@pytest.fixture
def client(db_session):
def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
yield TestClient(app)
app.dependency_overrides.clear()
```
Key decisions in this pattern:
- `scope='session'` for `create_tables` — run schema creation once for the whole test run, not per-test.
- Per-test `db_session` wraps each test in a transaction and rolls it back on teardown. Every test starts with a clean schema without dropping and re-creating tables.
- The `client` fixture injects the same session the test holds, so you can read the app's writes directly from `db_session` without a second query through a separate connection.
## BackgroundTasks: `TestClient` vs `AsyncClient` behaviour
FastAPI's `BackgroundTasks` runs functions after the response is returned to the client. The behaviour in tests differs between the two clients:
```python
from fastapi import BackgroundTasks
results = []
def record(value: str):
results.append(value)
@app.post('/jobs/')
def create_job(bt: BackgroundTasks):
bt.add_task(record, 'done')
return {'status': 'queued'}
```
**With `TestClient`**, background tasks run **synchronously before** `TestClient` returns the response. By the time your assertion runs, the task is already complete:
```python
def test_job_with_test_client():
results.clear()
response = client.post('/jobs/')
assert response.status_code == 200
assert 'done' in results # task ran synchronously before TestClient returned
```
**With `AsyncClient` and `ASGITransport`**, background tasks also complete **before** `await async_client.post(...)` returns. Starlette invokes background tasks as part of the ASGI response lifecycle, and `ASGITransport` drives that lifecycle synchronously within the test coroutine — no separate asyncio task is scheduled that you could yield to:
```python
async def test_job_with_async_client(async_client):
results.clear()
response = await async_client.post('/jobs/')
assert response.status_code == 200
assert 'done' in results # task already ran before the response was returned
```
Both testing clients behave identically: background tasks complete before the response object is returned. This differs from production — a real server sends the HTTP response first and then runs tasks, so the client receives the response before the tasks finish. In tests with either client you can assert on task results immediately after the request.
## Testing custom exception handlers
By default, `TestClient` re-raises any server-side exception in the test process. If a route raises `ValueError`, the test sees a `ValueError`, not a 500 response — you cannot assert on the HTTP status.
When you have a custom exception handler and want to verify it returns the correct status and body, disable re-raising with `raise_server_exceptions=False`:
```python
from fastapi import Request
from fastapi.responses import JSONResponse
class AppError(Exception):
def __init__(self, code: int, message: str):
self.code = code
self.message = message
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
return JSONResponse(status_code=exc.code, content={'error': exc.message})
@app.get('/boom')
def boom():
raise AppError(409, 'conflict')
```
```python
def test_custom_error_handler():
error_client = TestClient(app, raise_server_exceptions=False)
response = error_client.get('/boom')
assert response.status_code == 409
assert response.json() == {'error': 'conflict'}
```
Without `raise_server_exceptions=False`, `TestClient` would propagate `AppError` into the test and the assertion lines would never execute. Keep this flag scoped to tests that specifically test error handling — for all other tests you want exceptions to propagate so that unexpected server errors cause immediate test failures.
## File uploads
Use the `files` parameter to send multipart file uploads through `TestClient`. The value is a dict mapping the form field name to a tuple `(filename, file-like-object, content-type)`:
```python
from fastapi import UploadFile, File
@app.post('/upload/')
async def upload_file(file: UploadFile = File(...)):
content = await file.read()
return {'filename': file.filename, 'size': len(content)}
```
```python
import io
def test_file_upload():
data = b'hello world'
response = client.post(
'/upload/',
files={'file': ('hello.txt', io.BytesIO(data), 'text/plain')},
)
assert response.status_code == 200
assert response.json() == {'filename': 'hello.txt', 'size': 11}
```
The tuple format is `(filename, file-like-object, content-type)`. The `content-type` part is optional — if omitted, httpx infers it from the filename extension. For binary or unknown files use `'application/octet-stream'`.
For multipart forms that mix regular fields with files, combine `data` and `files`:
```python
response = client.post(
'/upload/',
data={'description': 'profile picture'},
files={'avatar': ('avatar.png', io.BytesIO(png_bytes), 'image/png')},
)
```
## The application under test
All examples use a minimal FastAPI app with items, a dependency, and token auth:
```python
# myapp/main.py
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
app = FastAPI()
items_db: list = []
users_db = {'alice': 'secret'}
tokens_db: dict = {}
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/token')
def get_items_store():
return items_db
class Item(BaseModel):
name: str
price: float
@app.post('/token')
def login(form: OAuth2PasswordRequestForm = Depends()):
if users_db.get(form.username) != form.password:
raise HTTPException(status_code=400, detail='Bad credentials')
token = f'tok-{form.username}'
tokens_db[token] = form.username
return {'access_token': token, 'token_type': 'bearer'}
def get_current_user(token: str = Depends(oauth2_scheme)):
name = tokens_db.get(token)
if not name:
raise HTTPException(status_code=401)
return name
@app.get('/items/')
def list_items(store: list = Depends(get_items_store)):
return {'results': store}
@app.post('/items/', status_code=201)
def create_item(item: Item, store: list = Depends(get_items_store)):
record = item.model_dump()
store.append(record)
return record
@app.get('/me')
def get_profile(username: str = Depends(get_current_user)):
return {'username': username}
```
## Sync tests with `TestClient`
```python
from fastapi.testclient import TestClient
from myapp.main import app
client = TestClient(app)
def test_list_items_empty():
response = client.get('/items/')
assert response.status_code == 200
assert response.json() == {'results': []}
def test_create_item():
response = client.post('/items/', json={'name': 'Widget', 'price': 9.99})
assert response.status_code == 201
data = response.json()
assert data['name'] == 'Widget'
assert data['price'] == 9.99
```
The problem: `items_db` is a module-level list, so these two tests share state. If `test_create_item` runs first, `test_list_items_empty` fails because the list already contains an item.
## Isolating tests with `dependency_overrides`
Replace `get_items_store` with a function that returns a fresh list per test:
```python
import pytest
from fastapi.testclient import TestClient
from myapp.main import app, get_items_store
@pytest.fixture
def isolated_client():
store = []
def override():
return store
app.dependency_overrides[get_items_store] = override
yield TestClient(app), store
app.dependency_overrides.clear()
def test_create_and_list(isolated_client):
client, store = isolated_client
client.post('/items/', json={'name': 'Widget', 'price': 9.99})
response = client.get('/items/')
assert len(response.json()['results']) == 1
assert response.json()['results'][0]['name'] == 'Widget'
def test_store_is_isolated(isolated_client):
client, store = isolated_client
assert store == [] # previous test's item is gone
```
## Async tests with `AsyncClient`
```python
# pytest.ini: asyncio_mode = auto
import httpx
import pytest
from myapp.main import app, get_items_store
@pytest.fixture
async def async_client():
store = []
app.dependency_overrides[get_items_store] = lambda: store
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
yield client
app.dependency_overrides.clear()
async def test_list_items_async(async_client):
response = await async_client.get('/items/')
assert response.status_code == 200
assert response.json()['results'] == []
```
## Testing a protected endpoint with an auth fixture
```python
@pytest.fixture
def auth_client():
app.dependency_overrides[get_items_store] = lambda: []
with TestClient(app) as client:
resp = client.post(
'/token',
data={'username': 'alice', 'password': 'secret'},
)
token = resp.json()['access_token']
client.headers.update({'Authorization': f'Bearer {token}'})
yield client
app.dependency_overrides.clear()
def test_get_profile_authenticated(auth_client):
response = auth_client.get('/me')
assert response.status_code == 200
assert response.json() == {'username': 'alice'}
def test_get_profile_no_token():
client = TestClient(app)
response = client.get('/me')
assert response.status_code == 401
```
The `/token` endpoint uses `OAuth2PasswordRequestForm`, which reads from a form-encoded body — use `data={}`, not `json={}`. FastAPI returns 422 if you send JSON to this endpoint.
## `TestClient` vs `AsyncClient`
| | `TestClient` | `httpx.AsyncClient` |
|---|---|---|
| Import | `from fastapi.testclient import TestClient` | `import httpx` |
| Test function type | `def test_...` (sync) | `async def test_...` |
| Requires pytest-asyncio | No | Yes (`asyncio_mode = auto`) |
| Lifespan events | Yes (context manager) | Yes (context manager) |
| Best for | Most tests | Async-specific behaviour |
## `app.dependency_overrides`
```python
# Set override
app.dependency_overrides[original_dep] = replacement_dep
# Clean up after the test — ALWAYS
app.dependency_overrides.clear()
# Or remove a single override
del app.dependency_overrides[original_dep]
```
For `yield` dependencies, the replacement must also use `yield`:
```python
# Original (yield dep)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Override (yield dep)
def fake_db():
yield {} # in-memory dict instead of real session
```
For non-yield dependencies, a plain `return` or `lambda` works:
```python
app.dependency_overrides[get_store] = lambda: []
```
## Fixture pattern for isolated client
```python
@pytest.fixture
def client():
store = []
app.dependency_overrides[get_store] = lambda: store
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
```
## Auth header patterns
```python
# Set once for all requests on this client
client.headers.update({'Authorization': f'Bearer {token}'})
# Pass per-request
client.get('/me', headers={'Authorization': f'Bearer {token}'})
```
## pytest.ini for async FastAPI tests
```ini
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = session
```
## OAuth2 form data vs JSON
```python
# Correct — OAuth2PasswordRequestForm expects form-encoded body
client.post('/token', data={'username': 'alice', 'password': 'secret'})
# Wrong — returns 422
client.post('/token', json={'username': 'alice', 'password': 'secret'})
```
## Common assertions
```python
assert response.status_code == 200
assert response.json() == {'key': 'value'}
assert 'results' in response.json()
assert response.status_code == 422 # Pydantic validation error
assert response.status_code == 401 # missing/invalid token
assert response.status_code == 201 # created
```