FastAPI Testing: TestClient, Fixtures, Async, Mocks
1 · The lesson
readA pure function's tests check that add(2, 3) == 5. A web service's tests check what every user, partner, and downstream system actually depends on — the request/response contract. A unit-tested service with no endpoint tests is a service whose entire reason for existing is untested.
Run locally with pip install 'fastapi[standard]' pytest pytest-asyncio respx and pytest. Expected output shown in comments.
This lesson builds on testing — pytest, fixtures, marks, mocking. The new material is what changes when your code under test is a FastAPI app.
1. TestClient — In-Process, No Server
fastapi.testclient.TestClient runs your app in the same process as the test. No uvicorn, no port, no startup race. Under the hood it's httpx driving Starlette directly:
# test_main.py from fastapi.testclient import TestClient from main import app client = TestClient(app) def test_root(): r = client.get("/") assert r.status_code == 200 assert r.json() == {"message": "Hello, FastAPI!"}
Run with pytest. The whole roundtrip — request marshalling, dependency resolution, response serialisation — happens in your test process. No network, no flakiness, fast enough that you can run thousands per second in CI.
TestClient mirrors the httpx API: .get, .post, .put, .patch, .delete, with params=, json=, data=, headers=, files=, cookies=. Anything you can send to a real FastAPI server, you can send through TestClient. Lifespan handlers run when you use TestClient as a context manager (with TestClient(app) as client:) — important if your startup code is what opens the DB pool.
2. Testing POST with a JSON Body
def test_create_item(): r = client.post( "/items/", json={"name": "widget", "price": 9.99}, ) assert r.status_code == 201 body = r.json() assert body["name"] == "widget" assert body["price"] == 9.99 assert "id" in body
setup added so this can run · defines client
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) client = _AutoMock('client')
json= serialises the dict and sets Content-Type: application/json. The response is parsed by .json(). Two assertions worth keeping in mind:
- Status code first. A 422 means your Pydantic model rejected the payload — useful, but a different failure than "the create logic is wrong".
- Pick the fields that matter. Asserting on the entire response dict makes the test brittle to additive changes; assert on the contract — the fields the consumer depends on.
For validation errors, hit the same endpoint with a deliberately bad body and assert the 422 plus error structure:
def test_create_item_validation_error(): r = client.post("/items/", json={"name": "", "price": -1}) assert r.status_code == 422 errors = {e["loc"][-1]: e["type"] for e in r.json()["detail"]} assert errors["name"] == "string_too_short" assert errors["price"] == "greater_than"
setup added so this can run · defines client
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) client = _AutoMock('client')
3. Headers, Cookies, Files
def test_with_auth_header(): r = client.get("/me", headers={"Authorization": "Bearer test-token"}) assert r.status_code == 200 def test_with_cookie(): r = client.get("/dashboard", cookies={"session": "abc123"}) assert r.status_code == 200 def test_upload(): r = client.post( "/upload", files={"file": ("hello.txt", b"hello, world", "text/plain")}, ) assert r.status_code == 200 assert r.json() == {"filename": "hello.txt", "size": 12}
setup added so this can run · defines client
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) client = _AutoMock('client')
For uploads, files= takes a dict where each value is (filename, bytes, content_type). The two-tuple form (filename, bytes) works too — content type is guessed from the filename.
For form-encoded (non-JSON) bodies — old-school HTML forms, OAuth2 password flow — use data=:
r = client.post("/token", data={"username": "u", "password": "p"})
setup added so this can run · defines client
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) client = _AutoMock('client')
data= sends application/x-www-form-urlencoded; json= sends application/json. Don't mix them.
4. Fixtures — A Client and Isolated State
The single TestClient at module level works for trivial cases. Real test suites need:
- A fresh database per test (so tests don't leak state),
- A
TestClientthat uses that database via overridden dependencies, - Clean teardown after every test.
The pattern, all in conftest.py:
# tests/conftest.py import pytest from fastapi.testclient import TestClient from app.main import app from app.dependencies.db import get_db class FakeDB: def __init__(self): self.items: dict[int, dict] = {} self.next_id = 1 @pytest.fixture def db(): """Fresh in-memory DB per test.""" return FakeDB() @pytest.fixture def client(db): """TestClient with get_db overridden to return our fake.""" def override_get_db(): return db app.dependency_overrides[get_db] = override_get_db with TestClient(app) as c: yield c app.dependency_overrides.clear()
Every test that asks for client gets a TestClient wired to a brand-new FakeDB. The fixture closes over db so the test body can inspect the DB state directly:
def test_create_persists(client, db): client.post("/items/", json={"name": "x", "price": 1.0}) assert len(db.items) == 1
Two tests never see each other's data. Order-dependency bugs vanish. Adding a new test is one function, no setup boilerplate.
5. Overriding Dependencies in Tests
app.dependency_overrides[real_dep] = fake_dep — the killer feature from fastapi-deps. The full toolkit:
# tests/conftest.py from app.dependencies.auth import get_current_user from app.dependencies.db import get_db @pytest.fixture def authed_client(client): """Client that skips real auth — returns a fake admin user.""" app.dependency_overrides[get_current_user] = lambda: { "username": "test-admin", "scopes": ["read", "write", "admin"], } yield client # the outer client fixture clears overrides on its teardown
setup added so this can run · defines pytest, app
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) pytest = _AutoMock('pytest') app = _AutoMock('app')
Tests that need auth ask for authed_client. Tests that hit auth-protected routes without overriding get_current_user will hit the real auth check and get 401s — useful for confirming the auth path itself works.
Three patterns to memorise:
| Override | Replaces |
|---|---|
lambda: fake_value | Constant return value |
lambda: fake_obj | Object the test holds a reference to (assert on it later) |
A regular def fn(...) | When the override needs sub-dependencies of its own |
Always clear overrides between tests. The yield+clear() pattern in a fixture is the safest place.
6. Async Endpoint Tests with httpx.AsyncClient
TestClient is sync — it drives the app through Starlette's WSGI-style transport. That works for async def routes too (Starlette runs them on its own loop), but if you want your test code to be async — to await other coroutines alongside the request — use httpx.AsyncClient with the ASGITransport:
# test_async.py import pytest import httpx from httpx import ASGITransport from main import app @pytest.mark.asyncio async def test_async_endpoint(): transport = ASGITransport(app=app) async with httpx.AsyncClient( transport=transport, base_url="http://test" ) as ac: r = await ac.get("/joke") assert r.status_code == 200 assert "value" in r.json()
setup added so this can run · defines ac
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) ac = _AutoMock('ac')
You'll need pytest-asyncio (or anyio with the pytest-anyio plugin) installed and an entry in pyproject.toml:
[tool.pytest.ini_options] asyncio_mode = "auto" # treats async test functions as async without explicit marks
When to use which:
TestClient— synchronous test bodies, simpler = client.get(...)style. Default for most tests.httpx.AsyncClient+ ASGI transport — when the test body itself needs to await something (e.g. fire concurrent requests withasyncio.gather, await a database fixture, check websocket behaviour).
For WebSocket testing, TestClient has a built-in websocket_connect context manager — no async needed:
def test_ws_echo(): with client.websocket_connect("/ws/echo") as ws: ws.send_text("hello") assert ws.receive_text() == "echo: hello"
setup added so this can run · defines client
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) client = _AutoMock('client')
7. Parametrising Endpoint Tests
Validation paths multiply fast — bad name, bad price, bad category, missing field. @pytest.mark.parametrize keeps them in one function:
import pytest @pytest.mark.parametrize( "payload,status,error_field", [ ({"name": "", "price": 1.0}, 422, "name"), ({"name": "x", "price": -1}, 422, "price"), ({"name": "x"}, 422, "price"), # missing ({"price": 1.0}, 422, "name"), # missing ({"name": "x", "price": 1.0}, 201, None), # happy path ], ) def test_create_item_validation(client, payload, status, error_field): r = client.post("/items/", json=payload) assert r.status_code == status if error_field: bad_fields = {e["loc"][-1] for e in r.json()["detail"]} assert error_field in bad_fields
Five tests, one function. A failure points at the specific row. The id column at the start of each parametrize entry can be customised with pytest.param(..., id="missing_price") for clearer output.
8. Mocking External Services with respx
Your route calls httpx.AsyncClient().get("https://api.upstream.com/..."). The test shouldn't hit the real upstream — too slow, too flaky, possibly costly. respx intercepts httpx calls and returns canned responses:
import respx import httpx @respx.mock def test_dashboard(client): respx.get("https://api.upstream.com/users/1").mock( return_value=httpx.Response(200, json={"id": 1, "name": "Margaret"}) ) respx.get("https://api.upstream.com/users/1/posts").mock( return_value=httpx.Response(200, json=[{"title": "hi"}]) ) r = client.get("/dashboard/1") assert r.status_code == 200 assert r.json()["user"]["name"] == "Margaret"
@respx.mock activates the interception for the duration of the function; routes not matched raise an error (configurable). respx.get(...).mock(...) registers a handler. Use it to test the happy path, then add tests that mock a 500 from the upstream and assert your route handles it cleanly.
For the standard library's requests, use responses; for general HTTP mocking that also captures aiohttp, use pytest-httpx. The choice depends on which client your code uses — they're not interchangeable.
9. Snapshot Testing — Briefly
Asserting on every field of a 30-key response is tedious and noisy. Snapshot tests capture the response once, store it in a file, and fail when the response changes. syrupy is the pytest-friendly option:
def test_user_response_shape(client, snapshot): r = client.get("/users/1") assert r.json() == snapshot
First run writes a snapshot file. Subsequent runs compare. When a real change happens, run pytest --snapshot-update to refresh — then review the diff in your PR.
Useful for the "I want to know if anything about this response changed" smoke test. Don't use it as your only assertion — a snapshot tells you the response changed, not whether the change was correct.
10. Integration vs Unit — Where to Draw the Line
A common pattern that doesn't scale — every behaviour tested only through the HTTP layer:
- Slow (request marshalling, dep resolution, JSON serialisation on every assert),
- Hard to set up edge cases (you have to find a JSON body that produces the internal state you want),
- Hides where bugs live (a failed endpoint test could be in routing, validation, the service, or the DB).
The healthier split:
- Unit tests exercise services and pure logic with no FastAPI involved. Fast, surgical, drive every branch.
- Integration tests exercise routes with
TestClientand a fake DB. Cover the happy path and a representative 4xx for each endpoint. - End-to-end tests (separate suite, slower CI lane) drive a real
uvicornagainst a real Postgres in a container. Run on PRs and pre-deploy.
Mock at the boundary — your own services don't get mocked, external HTTP/DB/queue clients do.
11. CI Integration
pytest --cov and a GitHub Actions matrix is the table-stakes setup:
# .github/workflows/test.yml
name: tests
on: [push, pull_request]
jobs:
test:
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e '.[dev]'
- run: pytest --cov=app --cov-report=term-missingThree Python versions, all your tests, coverage reported inline. See devops-github-actions for the full CI playbook — caching pip, splitting fast/slow lanes, uploading coverage artefacts, gating merges on green.
Common Mistakes
1. Not isolating the test DB
A shared DB between tests means test order matters. One test creates a user, another finds it, a third deletes it. Reorder them and the suite fails for no real reason. Per-test isolation via a fresh fake (or a transactional rollback in a real-DB integration suite) is non-negotiable.
2. Mocking too much
def test_create_user(mocker): mocker.patch("app.routers.users.create_user_service") # mocked the function under test r = client.post("/users/", json={...}) assert r.status_code == 201
setup added so this can run · defines client
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) client = _AutoMock('client')
The test passes when the endpoint is completely broken — the real service never runs. Mock at the external boundary (DB driver, HTTP client, queue), let your own code execute. If you find yourself mocking your own service, the seam belongs lower.
3. Not testing error paths
A test suite that only proves the happy path is the test equivalent of "works on my machine". Explicitly assert 401 on a missing token, 404 on a missing id, 422 on a bad body, 409 on a conflict. The error paths are where users actually hit your code at the edge.
4. Forgetting app.dependency_overrides.clear()
Override leaks into the next test, hides a regression, you debug for an hour. Always clear in a fixture teardown (yield then clear) — never trust manual cleanup at the end of a test body, because an assertion failure short-circuits it.
5. Treating TestClient like a real HTTP client
TestClient runs in-process. It doesn't respect localhost URLs, doesn't spawn workers, doesn't reproduce production-load behaviour. For load tests, deploy and hit it with locust/k6. TestClient is for correctness.
6. Coverage as the goal
90% coverage with weak assertions ships bugs every day. Coverage tells you what isn't tested — it doesn't tell you what is tested well. Read the missing lines, decide if they matter, write tests with meaningful assertions for the ones that do.
🎯 Your Turn — A Complete Test Module
You're given this tiny app:
# app/main.py from fastapi import FastAPI, HTTPException, status from pydantic import BaseModel, Field from typing import Annotated from .dependencies import get_store class ItemIn(BaseModel): name: str = Field(min_length=1, max_length=50) price: float = Field(gt=0) class ItemOut(ItemIn): id: int app = FastAPI() @app.get("/items", response_model=list[ItemOut]) def list_items(store = Depends(get_store)): return list(store.items.values()) @app.post("/items", response_model=ItemOut, status_code=201) def create_item(item: ItemIn, store = Depends(get_store)): new = ItemOut(id=store.next_id, **item.model_dump()) store.items[store.next_id] = new store.next_id += 1 return new @app.get("/items/{item_id}", response_model=ItemOut) def get_item(item_id: int, store = Depends(get_store)): item = store.items.get(item_id) if not item: raise HTTPException(404, f"Item {item_id} not found") return item
setup added so this can run · defines Depends
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def Depends(*_a, **_kw): print('-> Depends() called') return _AutoMock('Depends()')
Write tests/test_items.py that covers:
1. A conftest.py fixture providing an isolated in-memory Store per test, plus a client fixture wiring it to app.dependency_overrides.
2. A happy-path test: POST creates, GET list returns it, GET by id returns it.
3. A 404 test for a missing id.
4. A parametrised validation test that hits POST with three bad bodies (empty name, negative price, missing price) and asserts 422 each time, with the expected error field.
# tests/conftest.py import pytest from fastapi.testclient import TestClient from app.main import app, get_store # TODO 1: Define Store class (dict + counter) and store/client fixtures. # tests/test_items.py # TODO 2: test_happy_path — POST then GET list then GET by id # TODO 3: test_get_missing_404 # TODO 4: parametrised test_create_validation
Hint 1 — Wiring the override
The dependency in the app isget_store. In the fixture, build a fresh Store(), then app.dependency_overrides[get_store] = lambda: store. Yield the client, then app.dependency_overrides.clear() on teardown. The store fixture should be requested separately so the test body can inspect it.
Hint 2 — Asserting on the error field in 422 responses
FastAPI's 422 body is{"detail": [{"loc": ["body", "name"], "type": "string_too_short", "msg": "...", ...}, ...]}. To check which field failed, build a set {e["loc"][-1] for e in r.json()["detail"]} and assert membership.
Show full solution
# tests/conftest.py import pytest from fastapi.testclient import TestClient from app.main import app from app.dependencies import get_store class Store: def __init__(self): self.items: dict[int, dict] = {} self.next_id = 1 @pytest.fixture def store(): return Store() @pytest.fixture def client(store): app.dependency_overrides[get_store] = lambda: store with TestClient(app) as c: yield c app.dependency_overrides.clear()
# tests/test_items.py import pytest def test_create_list_and_fetch_happy_path(client, store): # POST r = client.post("/items", json={"name": "widget", "price": 9.99}) assert r.status_code == 201 created = r.json() assert created["id"] == 1 assert created["name"] == "widget" assert created["price"] == 9.99 # The store should now hold one item assert len(store.items) == 1 # GET list r = client.get("/items") assert r.status_code == 200 body = r.json() assert len(body) == 1 assert body[0]["id"] == 1 # GET by id r = client.get("/items/1") assert r.status_code == 200 assert r.json()["name"] == "widget" def test_get_missing_returns_404(client): r = client.get("/items/999") assert r.status_code == 404 assert "999" in r.json()["detail"] @pytest.mark.parametrize( "payload, expected_field", [ ({"name": "", "price": 1.0}, "name"), # empty name ({"name": "x", "price": -1.0}, "price"), # negative price ({"name": "x"}, "price"), # missing price ({"price": 1.0}, "name"), # missing name ({"name": "x" * 51, "price": 1.0}, "name"), # too long ], ids=["empty_name", "negative_price", "missing_price", "missing_name", "name_too_long"], ) def test_create_validation_errors(client, payload, expected_field): r = client.post("/items", json=payload) assert r.status_code == 422 bad_fields = {e["loc"][-1] for e in r.json()["detail"]} assert expected_field in bad_fields def test_isolation_between_tests(client, store): """The store should be empty for every test — proving fixture isolation.""" assert store.items == {} r = client.get("/items") assert r.json() == []
The shape of this suite is the production template for a small FastAPI service:
conftest.pyholds the sharedstoreandclientfixtures. Every test gets a brand-newStore, so no test depends on another's state. The override is applied and cleared inside theclientfixture — the discipline that keeps the next test honest.- One happy-path test per resource drives the full CRUD flow through real HTTP — POST then GET-list then GET-by-id — proving the routes are wired correctly and the dependency override didn't break anything.
- One 404 test confirms the explicit error path works. Without this, your 404 logic is untested no matter how many happy-path tests pass.
- One parametrised validation test with five rows replaces five copy-pasted functions. Adding a new validation rule is one new row.
- The isolation test at the bottom is a cheap insurance — it fails loudly if a future change accidentally makes the store fixture session-scoped.
Three more layers belong in a real test module — auth (override get_current_user), the integration-test variant against a real Postgres in a Docker container (devops-docker), and a snapshot test for the response shape (syrupy). For everything below the route — services, validators, business rules — write unit tests in tests/unit/ with no TestClient at all. They run in milliseconds and tell you exactly which function broke.
What You Learned
TestClient(app)runs the app in-process — no server, no port, fast.- Lifespan handlers run when
TestClientis used as a context manager (with TestClient(app) as client:). - The
httpx-style API gives you.get/.post/.put/.deletewithjson=,data=,headers=,files=,cookies=. - Fixtures in
conftest.pyfor the client + an isolated per-test store are the canonical setup. app.dependency_overridesreplaces any dependency — DB, auth, settings — with a fake. Always clear it in a fixture teardown.httpx.AsyncClient(transport=ASGITransport(app=app))lets async test bodies talk to your async app. Use it when the test itself needs to await.- WebSocket tests use
client.websocket_connect— sync, no asyncio needed. @pytest.mark.parametrizecollapses N copy-pasted validation tests into one function — and the failure tells you which row broke.respxmockshttpxcalls so tests don't hit real upstreams. Mock at the boundary, not your own code.- Test the error paths explicitly — 401, 404, 422, 409, 500. Happy-path-only suites give false confidence.
- Unit vs integration: services with no FastAPI, routes via
TestClient, end-to-end via realuvicornagainst a real DB in a separate lane. - CI with
pytest --covand a matrix of Python versions — see devops-github-actions.
Next: Django Basics — the third pillar of Python web work, with a very different philosophy from FastAPI: batteries included, ORM-first, admin UI for free, and the conventions to keep a large team productive.