Python · Testing with pytest · Expert

Testing FastAPI Apps

5 tasks

Testing FastAPI Apps: TestClient, AsyncClient, dependency_overrides

#
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')}, ) ```

FastAPI Testing Patterns in Practice

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

FastAPI Testing Reference

#
## `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 ```
01

Test a FastAPI GET endpoint with TestClient

#

You have a FastAPI app with `GET /items/` that returns `{'results': [...]}`. Write a test using `TestClient` that: 1. Sends `GET /items/` 2. Asserts status 200 3. Asserts the response JSON has a `results` key 4. Asserts `results` is a list

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()
items = [{'name': 'Widget', 'price': 9.99}]

@app.get('/items/')
def list_items():
    return {'results': items}


def test_list_items():
    pass
Solution
from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()
items = [{'name': 'Widget', 'price': 9.99}]

@app.get('/items/')
def list_items():
    return {'results': items}


client = TestClient(app)


def test_list_items():
    response = client.get('/items/')
    assert response.status_code == 200
    data = response.json()
    assert 'results' in data
    assert isinstance(data['results'], list)
02

Test a POST endpoint and assert the response body

#

You have `POST /items/` that accepts `{'name': str, 'price': float}` as a JSON body, creates the item, and returns it with HTTP 201. Write two tests: 1. **test_create_item** — post `{'name': 'Gadget', 'price': 24.99}` and assert: status 201, `name == 'Gadget'`, `price == 24.99`. 2. **test_create_item_missing_field** — post without `price` and assert status 422 (FastAPI's Pydantic validation error).

from fastapi import FastAPI
from pydantic import BaseModel
from fastapi.testclient import TestClient

app = FastAPI()

class Item(BaseModel):
    name: str
    price: float

@app.post('/items/', status_code=201)
def create_item(item: Item):
    return item.model_dump()

client = TestClient(app)


def test_create_item():
    pass


def test_create_item_missing_field():
    pass
Solution
from fastapi import FastAPI
from pydantic import BaseModel
from fastapi.testclient import TestClient

app = FastAPI()

class Item(BaseModel):
    name: str
    price: float

@app.post('/items/', status_code=201)
def create_item(item: Item):
    return item.model_dump()

client = TestClient(app)


def test_create_item():
    response = client.post('/items/', json={'name': 'Gadget', 'price': 24.99})
    assert response.status_code == 201
    data = response.json()
    assert data['name'] == 'Gadget'
    assert data['price'] == 24.99


def test_create_item_missing_field():
    response = client.post('/items/', json={'name': 'Gadget'})
    assert response.status_code == 422
03

Use dependency_overrides to isolate test state

#

You have a FastAPI app where `GET /items/` and `POST /items/` share a module-level list through a `get_store` dependency. Tests pollute each other because they all write to the same list. Write a pytest fixture `isolated_client` that: 1. Creates a fresh `[]` for each test. 2. Sets `app.dependency_overrides[get_store]` to a function returning that list. 3. Yields a `TestClient`. 4. Clears `app.dependency_overrides` after the test. Write two tests using this fixture that prove isolation — neither test sees the other's data.

from fastapi import FastAPI, Depends
from pydantic import BaseModel
from fastapi.testclient import TestClient
import pytest

app = FastAPI()
shared_store: list = []

def get_store():
    return shared_store

class Item(BaseModel):
    name: str

@app.get('/items/')
def list_items(store: list = Depends(get_store)):
    return {'results': store}

@app.post('/items/', status_code=201)
def create_item(item: Item, store: list = Depends(get_store)):
    record = item.model_dump()
    store.append(record)
    return record


@pytest.fixture
def isolated_client():
    pass


def test_create_item(isolated_client):
    pass


def test_store_starts_empty(isolated_client):
    pass
Solution
from fastapi import FastAPI, Depends
from pydantic import BaseModel
from fastapi.testclient import TestClient
import pytest

app = FastAPI()
shared_store: list = []

def get_store():
    return shared_store

class Item(BaseModel):
    name: str

@app.get('/items/')
def list_items(store: list = Depends(get_store)):
    return {'results': store}

@app.post('/items/', status_code=201)
def create_item(item: Item, store: list = Depends(get_store)):
    record = item.model_dump()
    store.append(record)
    return record


@pytest.fixture
def isolated_client():
    store = []
    app.dependency_overrides[get_store] = lambda: store
    yield TestClient(app)
    app.dependency_overrides.clear()


def test_create_item(isolated_client):
    isolated_client.post('/items/', json={'name': 'Widget'})
    response = isolated_client.get('/items/')
    assert len(response.json()['results']) == 1
    assert response.json()['results'][0]['name'] == 'Widget'


def test_store_starts_empty(isolated_client):
    response = isolated_client.get('/items/')
    assert response.json()['results'] == []
04

Write an async test with httpx.AsyncClient

#

You have a FastAPI app with `GET /items/` that returns `{'results': [...]}`. Set `asyncio_mode = auto` in `pytest.ini`. Write: 1. An `async` pytest fixture `async_client` that creates `httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test')` using `async with ... yield`. 2. An async test `test_list_items_async` that awaits `GET /items/` and asserts status 200 and a `results` key in the response.

# pytest.ini:
# [pytest]
# asyncio_mode = auto

import httpx
import pytest
from fastapi import FastAPI

app = FastAPI()
items = [{'name': 'Widget'}]

@app.get('/items/')
def list_items():
    return {'results': items}


@pytest.fixture
async def async_client():
    pass


async def test_list_items_async(async_client):
    pass
Solution
# pytest.ini:
# [pytest]
# asyncio_mode = auto

import httpx
import pytest
from fastapi import FastAPI

app = FastAPI()
items = [{'name': 'Widget'}]

@app.get('/items/')
def list_items():
    return {'results': items}


@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_list_items_async(async_client):
    response = await async_client.get('/items/')
    assert response.status_code == 200
    assert 'results' in response.json()
05

Test a protected endpoint with an auth token fixture

#

You have a FastAPI app with: - `POST /token` — accepts OAuth2 form data, returns `{'access_token': ..., 'token_type': 'bearer'}` - `GET /me` — requires a valid Bearer token, returns `{'username': ...}`; raises 401 without one Write a pytest fixture `auth_client` that: 1. Creates a `TestClient`. 2. Posts credentials to `/token` using `data={}` (form-encoded) and extracts the token. 3. Sets `Authorization: Bearer <token>` on the client for all subsequent requests. 4. Yields the authenticated client. Write: - `test_get_profile_authenticated` — asserts 200 and `{'username': 'alice'}`. - `test_get_profile_no_token` — uses a plain `TestClient`, asserts 401.

import pytest
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.testclient import TestClient

app = FastAPI()
users = {'alice': 'secret'}
tokens = {}
oauth2 = OAuth2PasswordBearer(tokenUrl='/token')

@app.post('/token')
def login(form: OAuth2PasswordRequestForm = Depends()):
    if users.get(form.username) != form.password:
        raise HTTPException(status_code=400, detail='Bad credentials')
    token = f'tok-{form.username}'
    tokens[token] = form.username
    return {'access_token': token, 'token_type': 'bearer'}

def get_user(token: str = Depends(oauth2)):
    name = tokens.get(token)
    if not name:
        raise HTTPException(status_code=401)
    return name

@app.get('/me')
def me(username: str = Depends(get_user)):
    return {'username': username}


@pytest.fixture
def auth_client():
    pass


def test_get_profile_authenticated(auth_client):
    pass


def test_get_profile_no_token():
    pass
Solution
import pytest
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.testclient import TestClient

app = FastAPI()
users = {'alice': 'secret'}
tokens = {}
oauth2 = OAuth2PasswordBearer(tokenUrl='/token')

@app.post('/token')
def login(form: OAuth2PasswordRequestForm = Depends()):
    if users.get(form.username) != form.password:
        raise HTTPException(status_code=400, detail='Bad credentials')
    token = f'tok-{form.username}'
    tokens[token] = form.username
    return {'access_token': token, 'token_type': 'bearer'}

def get_user(token: str = Depends(oauth2)):
    name = tokens.get(token)
    if not name:
        raise HTTPException(status_code=401)
    return name

@app.get('/me')
def me(username: str = Depends(get_user)):
    return {'username': username}


@pytest.fixture
def auth_client():
    client = TestClient(app)
    resp = client.post(
        '/token',
        data={'username': 'alice', 'password': 'secret'},
    )
    token = resp.json()['access_token']
    client.headers.update({'Authorization': f'Bearer {token}'})
    yield client
    tokens.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