FastAPI · lesson 14 of 19
Beginner
6Intermediate
10- 7 FastAPI Pydantic Validation: Field Constraints and Nested Models
- 8 FastAPI Validators and Serialization with Pydantic
- 9 FastAPI CRUD with SQLModel and SQLite
- 10 FastAPI Database Migrations with Alembic and SQLModel
- 11 FastAPI Authentication: Password Hashing, OAuth2 and JWT
- 12 FastAPI Roles, CORS and Settings from Environment Variables
- 13 FastAPI Dependency Injection: Depends, yield and Overrides
- 14 Testing FastAPI with pytest, TestClient and httpx
- 15 FastAPI with Async PostgreSQL: Pools, Transactions, and Real Errors
- 16 Sync vs Async Endpoints in FastAPI, Measured
Advanced
3Also part of FastAPI
6Testing FastAPI with pytest, TestClient and httpx
Test FastAPI with pytest: TestClient, fixtures that swap in a test database via dependency_overrides, async tests over ASGITransport, and real failures.
By SkillByExample · Published
Tests That Call the App Directly
Every lesson so far has checked its app by running it and looking at the output. A test suite does that automatically, after every change. FastAPI’s TestClient sends requests straight into the app in the same process, with no server and no port, and pytest runs the checks. This lesson tests a small SQLModel app. It finds a real bug in the tests themselves, fixes it with dependency_overrides, then adds async tests.
Outputs come from FastAPI 0.143.0, Starlette 1.7.0, Pydantic 2.14.1, SQLModel 0.0.48, SQLAlchemy 2.1.4, pytest 9.1.1, anyio 4.15.1 and httpx2 2.13.1 on Python 3.12. Install with pip install fastapi sqlmodel pytest httpx2. If pytest is new to you, Pytest Fixtures covers the yield fixtures used here.
The App Under Test
It’s the SQLModel CRUD pattern, cut down to create and list. The models and the engine go in db.py:
from sqlmodel import Field, SQLModel, create_engine
class ItemIn(SQLModel):
name: str = Field(min_length=1)
class Item(ItemIn, table=True):
id: int | None = Field(default=None, primary_key=True)
engine = create_engine("sqlite:///shop.db")
SQLModel.metadata.create_all(engine)
Save the app next to it as main.py:
from typing import Annotated
from fastapi import Depends, FastAPI
from sqlmodel import Session, select
from db import Item, ItemIn, engine
def get_session():
with Session(engine) as session:
yield session
SessionDep = Annotated[Session, Depends(get_session)]
app = FastAPI()
@app.post("/items", status_code=201)
def create(item: ItemIn, session: SessionDep) -> Item:
row = Item.model_validate(item)
session.add(row)
session.commit()
session.refresh(row)
return row
@app.get("/items")
def list_items(session: SessionDep) -> list[Item]:
return session.exec(select(Item)).all()
A First Test File, and Its Hidden Bug
pytest collects files named test_*.py and runs every function named test_* in them. Save as test_main.py next to main.py:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_create_item():
r = client.post("/items", json={"name": "mug"})
assert r.status_code == 201
assert r.json() == {"name": "mug", "id": 1}
def test_blank_name_is_rejected():
r = client.post("/items", json={"name": ""})
assert r.status_code == 422
assert r.json()["detail"][0]["loc"] == ["body", "name"]
def test_list_items():
assert client.get("/items").json() == [{"name": "mug", "id": 1}]
Run it twice:
$ pytest -q
... [100%]
3 passed in 0.85s
$ pytest -q
F.F [100%]
====================================================== FAILURES =======================================================
__________________________________________________ test_create_item ___________________________________________________
def test_create_item():
r = client.post("/items", json={"name": "mug"})
assert r.status_code == 201
> assert r.json() == {"name": "mug", "id": 1}
E AssertionError: assert {'id': 2, 'name': 'mug'} == {'name': 'mug', 'id': 1}
E
E Omitting 1 identical items, use -vv to show
E Differing items:
E {'id': 2} != {'id': 1}
E Use -v to get more diff
test_main.py:11: AssertionError
___________________________________________________ test_list_items ___________________________________________________
def test_list_items():
> assert client.get("/items").json() == [{"name": "mug", "id": 1}]
E AssertionError: assert [{'id': 1, 'n...name': 'mug'}] == [{'name': 'mug', 'id': 1}]
E
E Left contains one more item: {'id': 2, 'name': 'mug'}
E Use -v to get more diff
test_main.py:21: AssertionError
=============================================== short test summary info ===============================================
FAILED test_main.py::test_create_item - AssertionError: assert {'id': 2, 'name': 'mug'} == {'name': 'mug', 'id': 1}
FAILED test_main.py::test_list_items - AssertionError: assert [{'id': 1, 'n...name': 'mug'}] == [{'name': 'mug', 'id'...
2 failed, 1 passed in 1.03s
The app didn’t change, but the second run failed. The tests write to the real shop.db, so the first run’s mug was still there: the new one got id 2, and the list had two items. pytest’s assertion rewriting shows exactly which keys differ. There’s a second, quieter bug too: test_list_items only passed the first time because test_create_item happened to run before it. Tests that depend on leftover data or on the order they run in break in CI, and they break on the day you run a single test by name. Each test needs its own empty database.
A Test Database with dependency_overrides
The routes get their session from get_session, so a test can override that dependency with a session on a throwaway database. Fixtures in conftest.py are available to every test in the directory without an import. Save as conftest.py:
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from main import app, get_session
@pytest.fixture
def session():
engine = create_engine("sqlite://")
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
@pytest.fixture
def client(session):
app.dependency_overrides[get_session] = lambda: session
yield TestClient(app)
app.dependency_overrides.clear()
Then change test_main.py so that each test asks for the client fixture and sets up its own data:
def test_create_item(client):
r = client.post("/items", json={"name": "mug"})
assert r.status_code == 201
assert r.json() == {"name": "mug", "id": 1}
def test_blank_name_is_rejected(client):
r = client.post("/items", json={"name": ""})
assert r.status_code == 422
assert r.json()["detail"][0]["loc"] == ["body", "name"]
def test_list_items(client):
client.post("/items", json={"name": "lamp"})
assert client.get("/items").json() == [{"name": "lamp", "id": 1}]
$ pytest -q --tb=no
F.F [100%]
=============================================== short test summary info ===============================================
FAILED test_main.py::test_create_item - sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: item
FAILED test_main.py::test_list_items - sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: item
2 failed, 1 passed in 0.06s
create_all just made the item table, yet the route can’t find it. sqlite:// is an in-memory database, and each connection to it gets its own private, empty one. FastAPI runs def routes in a worker thread, and SQLAlchemy’s pool for in-memory SQLite hands every thread its own connection. So the fixture created the table in one database and the route queried another. (The 422 test passed because validation fails before the route touches the database.) The fix is to make every thread share a single connection. Change the engine in conftest.py:
from sqlalchemy.pool import StaticPool
engine = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
)
$ pytest -q test_main.py
... [100%]
3 passed in 0.03s
$ pytest -q test_main.py
... [100%]
3 passed in 0.05s
StaticPool keeps exactly one connection and gives it to everyone. check_same_thread=False lets the worker thread use a connection that the test thread created. Now the suite passes on every run, in any order, and in a few hundredths of a second, because nothing touches the disk. The session fixture is function-scoped, the default, so each test gets a brand-new database. The client fixture’s teardown clears the override after each test, even a failing one, so it can’t leak into the next. Importing main still creates shop.db, because db.py builds the engine at import time, but no test writes to it any more.
Async Tests with AsyncClient
TestClient is synchronous, so tests are plain def functions even when the routes are async def. When the test itself must await something, for example an async database call to set up data, use httpx2’s AsyncClient with an ASGITransport that sends requests into the app. Save as test_async.py:
from httpx2 import ASGITransport, AsyncClient
from main import app, get_session
async def test_create_then_list(session):
app.dependency_overrides[get_session] = lambda: session
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
created = await ac.post("/items", json={"name": "lamp"})
listed = await ac.get("/items")
app.dependency_overrides.clear()
assert created.status_code == 201
assert listed.json() == [{"name": "lamp", "id": 1}]
$ pytest -q test_async.py
F [100%]
====================================================== FAILURES =======================================================
________________________________________________ test_create_then_list ________________________________________________
async def functions are not natively supported.
You need to install a suitable plugin for your async framework, for example:
- anyio
- pytest-asyncio
- pytest-tornasync
- pytest-trio
- pytest-twisted
=============================================== short test summary info ===============================================
FAILED test_async.py::test_create_then_list - Failed: async def functions are not natively supported.
1 failed in 0.01s
pytest can’t run a coroutine by itself, and this happened even though the anyio plugin was installed: anyio comes with FastAPI, through Starlette. The plugin only runs tests it’s asked to run, one marker at a time. pytest-asyncio in its default strict mode behaves the same way. FastAPI’s docs use anyio’s marker. Add import pytest at the top and mark the test:
@pytest.mark.anyio
async def test_create_then_list(session):
$ pytest -q -rA
.... [100%]
======================================================= PASSES ========================================================
=============================================== short test summary info ===============================================
PASSED test_async.py::test_create_then_list[asyncio]
PASSED test_main.py::test_create_item
PASSED test_main.py::test_blank_name_is_rejected
PASSED test_main.py::test_list_items
4 passed in 0.04s
-rA lists every test’s result. The [asyncio] suffix comes from anyio’s anyio_backend fixture, which defaults to asyncio. base_url="http://test" is required, but no request leaves the process. The sqlite:// session from conftest.py works here too, because StaticPool doesn’t care which thread asks. One weakness remains: the override is set and cleared inside the test, so if an await raised, clear() would never run. Practice task 1 moves it into a fixture.
Practice
- Move the async client into an
async_clientfixture inconftest.py, so the override is always cleared. Use it in two tests, one of which checks that the database starts empty.
Solution
Add to conftest.py (with from httpx2 import ASGITransport, AsyncClient at the top):
@pytest.fixture
async def async_client(session):
app.dependency_overrides[get_session] = lambda: session
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
test_async.py:
import pytest
@pytest.mark.anyio
async def test_create_then_list(async_client):
created = await async_client.post("/items", json={"name": "lamp"})
assert created.status_code == 201
assert (await async_client.get("/items")).json() == [{"name": "lamp", "id": 1}]
@pytest.mark.anyio
async def test_starts_empty(async_client):
assert (await async_client.get("/items")).json() == []
$ pytest -v --no-header test_async.py
================================================= test session starts =================================================
collecting ... collected 2 items
test_async.py::test_create_then_list[asyncio] PASSED [ 50%]
test_async.py::test_starts_empty[asyncio] PASSED [100%]
================================================== 2 passed in 0.03s ==================================================
--no-header hides the lines naming your platform, versions and paths. An async yield fixture works for anyio-marked tests. The code after yield is teardown, so it runs whether the test passed or failed.
- Replace the single blank-name test with one parametrized test covering an empty name, a missing name, a number and
null, checking each error’stype.
Solution
Save as test_validation.py:
import pytest
@pytest.mark.parametrize(
"body, error_type",
[
({"name": ""}, "string_too_short"),
({}, "missing"),
({"name": 5}, "string_type"),
({"name": None}, "string_type"),
],
)
def test_bad_bodies_get_422(client, body, error_type):
r = client.post("/items", json=body)
assert r.status_code == 422
assert r.json()["detail"][0]["type"] == error_type
$ pytest -v --no-header test_validation.py
================================================= test session starts =================================================
collecting ... collected 4 items
test_validation.py::test_bad_bodies_get_422[body0-string_too_short] PASSED [ 25%]
test_validation.py::test_bad_bodies_get_422[body1-missing] PASSED [ 50%]
test_validation.py::test_bad_bodies_get_422[body2-string_type] PASSED [ 75%]
test_validation.py::test_bad_bodies_get_422[body3-string_type] PASSED [100%]
================================================== 4 passed in 0.04s ==================================================
5 isn’t turned into "5": Pydantic v2 doesn’t convert numbers to strings by default, so it’s a string_type error, the same as null. Each case gets its own test id, so a failure names the exact body.
- Test
app.py,deps.pyandsecurity.pyfrom Authentication:/mewithout a token must give 401 withWWW-Authenticate: Bearer, a real login must reach/me, and a third test should act as Bob without logging in.
Solution
Save as test_auth.py next to those three files. current_user lives in deps.py, but app.py imports it, so importing it from app gives the same function object the routes use:
import pytest
from fastapi.testclient import TestClient
from app import app, current_user
@pytest.fixture
def client():
yield TestClient(app)
app.dependency_overrides.clear()
def test_me_requires_a_token(client):
r = client.get("/me")
assert r.status_code == 401
assert r.headers["www-authenticate"] == "Bearer"
def test_login_then_me(client):
token = client.post("/token", data={"username": "ada", "password": "correct-horse"}).json()
r = client.get("/me", headers={"Authorization": f"Bearer {token['access_token']}"})
assert r.json() == {"username": "ada", "role": "admin"}
def test_me_as_bob_without_logging_in(client):
app.dependency_overrides[current_user] = lambda: "bob"
assert client.get("/me").json() == {"username": "bob", "role": "viewer"}
$ pytest -v --no-header test_auth.py
================================================= test session starts =================================================
collecting ... collected 3 items
test_auth.py::test_me_requires_a_token PASSED [ 33%]
test_auth.py::test_login_then_me PASSED [ 66%]
test_auth.py::test_me_as_bob_without_logging_in PASSED [100%]
================================================== 3 passed in 0.64s ==================================================
data= sends a form body, which the OAuth2 login route needs. The suite takes 0.64 s instead of a few hundredths of a second because security.py computes three Argon2 hashes at import and the login verifies one more. Test the real login flow once, then override current_user everywhere else: those tests stay fast and don’t depend on a password.