PythonMastery
intermediate 24 min read · lesson 8 of 12 in Web Frameworks

FastAPI Testing: TestClient, Fixtures, Async, Mocks

1 · The lesson

read

A 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:

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

python
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:

python
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

python
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=:

python
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 TestClient that uses that database via overridden dependencies,
  • Clean teardown after every test.

The pattern, all in conftest.py:

python
# 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:

python
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:

python
# 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:

OverrideReplaces
lambda: fake_valueConstant return value
lambda: fake_objObject 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:

python
# 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:

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, simple r = 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 with asyncio.gather, await a database fixture, check websocket behaviour).

For WebSocket testing, TestClient has a built-in websocket_connect context manager — no async needed:

python
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:

python
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:

python
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:

python
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 TestClient and 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 uvicorn against 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:

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

Three 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

python
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:

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

python
# 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 is get_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
python
# 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()
python
# 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.py holds the shared store and client fixtures. Every test gets a brand-new Store, so no test depends on another's state. The override is applied and cleared inside the client fixture — 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 TestClient is used as a context manager (with TestClient(app) as client:).
  • The httpx-style API gives you .get/.post/.put/.delete with json=, data=, headers=, files=, cookies=.
  • Fixtures in conftest.py for the client + an isolated per-test store are the canonical setup.
  • app.dependency_overrides replaces 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.parametrize collapses N copy-pasted validation tests into one function — and the failure tells you which row broke.
  • respx mocks httpx calls 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 real uvicorn against a real DB in a separate lane.
  • CI with pytest --cov and 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.