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

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

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

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

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, 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 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 also gives the test access to 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. 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:

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

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

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 the pytest plugin that comes with anyio) installed and an entry in pyproject.toml:

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 simple r = 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 with asyncio.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:

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

There are quickly many validation cases: 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

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:

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

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

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

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

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

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 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 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. 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.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, 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 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 standard 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. They are synchronous, so you don't need asyncio.
  • @pytest.mark.parametrize turns N copy-pasted validation tests into one function, and a failure tells you which row failed.
  • 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. 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 real uvicorn and a real DB in a separate CI job.
  • CI with pytest --cov and 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.