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 depends on: the request/response contract. If a service has unit tests but no endpoint tests, the part that other people use is not tested.
Run locally with pip install 'fastapi[standard]' pytest pytest-asyncio respx and pytest. Expected output is 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. There is no uvicorn, no port, and no waiting for a server to start. Internally, it uses httpx to call 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 round trip (building the request, resolving dependencies, serialising the response) happens in your test process. There is no network, so the tests are fast and don't fail at random.
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:). This is important if your startup code 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 things to keep in mind about assertions:
- Status code first. A 422 means your Pydantic model rejected the payload. That is useful to know, but it is a different failure from "the create logic is wrong".
- Pick the fields that matter. If you assert on the entire response dict, the test breaks every time a new field is added. Assert only on the fields the client depends on.
For validation errors, call the same endpoint with a body that is wrong on purpose, 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. Then the content type is guessed from the filename.
For form-encoded (non-JSON) bodies, such as classic HTML forms or the 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, and json= sends application/json. Don't mix them.
4. Fixtures: A Client and Isolated State
The single TestClient at module level works for very simple cases. Real test suites need:
- A fresh database per test (so one test's data doesn't reach another test),
- 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 also gives the test access to 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. Bugs that depend on test order go away. To add a new test, you write one function, with no setup code.
5. Overriding Dependencies in Tests
app.dependency_overrides[real_dep] = fake_dep is the main testing feature from fastapi-deps. Here are all the tools:
# 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. Every request runs as 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 go through the real auth check and get 401s. This is useful for checking that the auth code itself works.
Three patterns to remember:
| 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 synchronous: your test calls it without await. That works for async def routes too (Starlette runs them on its own event loop). But if you want your test code to be async, so it can await other coroutines next to 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 the pytest plugin that comes with anyio) installed and an entry in pyproject.toml:
[tool.pytest.ini_options] asyncio_mode = "auto" # runs async test functions without a @pytest.mark.asyncio mark
When to use which:
TestClient: synchronous test bodies, in the simpler = client.get(...)style. Use it for most tests.httpx.AsyncClient+ ASGI transport: when the test body itself needs to await something (e.g. send concurrent requests withasyncio.gather, await a database fixture, check websocket behaviour).
For WebSocket testing, TestClient has a built-in websocket_connect context manager, so you don't need async:
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
There are quickly many validation cases: 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
That is five tests in one function. A failure shows which row failed. pytest gives each row an id automatically, and you can set a clearer one with pytest.param(..., id="missing_price").
8. Mocking External Services with respx
Your route calls httpx.AsyncClient().get("https://api.upstream.com/..."). The test shouldn't call the real upstream: it is too slow, it can fail at random, and it may cost money. respx intercepts httpx calls and returns responses you prepared in advance:
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 intercepts calls while the function runs. Calls that match no route raise an error (you can change this). 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 that your route handles it correctly.
For requests (a third-party library, not part of the standard library), use responses. pytest-httpx is another way to mock httpx. The right tool depends on which client your code uses. They are not interchangeable.
9. Snapshot Testing: A Short Introduction
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 the response changes on purpose, run pytest --snapshot-update to update the file, and then review the diff in your PR.
This is useful as a quick check when you want to know if anything about a response changed. Don't use it as your only assertion. A snapshot tells you that the response changed, not whether the change was correct.
10. Integration vs Unit: Where to Draw the Line
A common approach that doesn't work well as the app grows is to test every behaviour only through the HTTP layer. It is:
- Slow (building the request, resolving dependencies and serialising JSON for every assert),
- Hard to set up edge cases (you have to find a JSON body that produces the internal state you want),
- Bad at showing where a bug is (a failed endpoint test could be caused by routing, validation, the service or the DB).
A better split:
- Unit tests test services and pure logic with no FastAPI involved. They are fast and precise, and they can test every branch.
- Integration tests test routes with
TestClientand a fake DB. Cover the happy path and a representative 4xx for each endpoint. - End-to-end tests (a separate suite, in a slower CI job) run a real
uvicornwith a real Postgres in a container. Run them on PRs and before each deploy.
Mock at the boundary: don't mock your own services, but do mock external HTTP, DB and queue clients.
11. CI Integration
pytest --cov and a GitHub Actions matrix is the basic setup every project needs:
# .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 guide: caching pip, splitting fast and slow jobs, uploading coverage artefacts, and allowing a merge only when all tests pass.
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. If you change their order, the suite fails even though the code is fine. Each test must have its own isolated data: a fresh fake, or a transaction that is rolled back in a real-DB integration suite.
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 even when the endpoint is completely broken, because the real service never runs. Mock at the external boundary (DB driver, HTTP client, queue), and let your own code run. If you are mocking your own service, mock something lower down instead.
3. Not testing error paths
A test suite that only checks the happy path gives you false confidence. Assert 401 on a missing token, 404 on a missing id, 422 on a bad body, and 409 on a conflict. Real users often send unusual requests, so they reach these error paths often.
4. Forgetting app.dependency_overrides.clear()
The override stays active in the next test and can hide a regression. Always clear it in a fixture teardown (yield, then clear). Don't rely on cleanup code at the end of a test body, because a failed assertion stops the test before that code runs.
5. Treating TestClient like a real HTTP client
TestClient runs in-process. It doesn't send real network requests, doesn't start workers, and doesn't behave like a server under production load. For load tests, deploy the app and test it with locust/k6. TestClient is for checking correctness.
6. Coverage as the goal
You can have 90% coverage with weak assertions and still ship bugs. Coverage tells you what is not 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 Depends, 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
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 does a POST, then GETs the list, then GETs by id # TODO 3: test_get_missing_404 # TODO 4: parametrised test_create_validation
Hint 1: Setting up 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. This proves fixture isolation.""" assert store.items == {} r = client.get("/items") assert r.json() == []
This suite is a good template for a small production 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, so it can't affect the next test.- One happy-path test per resource runs the full flow through HTTP requests (POST, then GET-list, then GET-by-id). It proves that the routes are connected 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. To test a new validation rule, you add one row.
- The isolation test at the bottom costs little. It fails with a clear error if a future change makes the store fixture session-scoped by mistake.
A real test module also needs three more things: auth tests (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, and fast tests.- 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 standard 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. They are synchronous, so you don't need asyncio. @pytest.mark.parametrizeturns N copy-pasted validation tests into one function, and a failure tells you which row failed.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. Suites that only test the happy path give false confidence.
- Unit vs integration: services with no FastAPI, routes via
TestClient, end-to-end tests with a realuvicornand a real DB in a separate CI job. - CI with
pytest --covand a matrix of Python versions (see devops-github-actions).
Next: Django Basics, the third major Python web framework. Its approach is very different from FastAPI's: many features are built in, the ORM is central, you get an admin UI without writing it, and it has conventions that help a large team work together.
Found this useful? Share it with a friend who’s learning Python.