Skip to content

Testing the FastAPI backend with pytest

A real test suite for the FitTrack backend — the exercises and workouts routers you built in Exercises API → and Workouts API →, exercised end to end through the actual FastAPI app.

The whole suite rests on three fixtures in a conftest.py:

  • an httpx.AsyncClient wired to the app with ASGITransport, so requests run in-process without a live server or network,
  • a Postgres session fixture that opens a transaction, hands it to the app, and rolls it back when the test ends — every test starts from the same clean database and never commits a real row,
  • a get_current_user dependency override that returns a fixed fake user id, so tests skip Supabase entirely instead of minting and decoding a JWT.

With those in place, the tests themselves read like plain HTTP: await client.post("/exercises", json=...), assert on the status and body, done. By the end, uv run pytest runs the exercises and workouts endpoints against a real database and comes back green.

The backend’s whole job is HTTP: a request comes in, get_current_user authenticates it, a router validates the body, a repository touches Postgres, a response goes out. A test that only calls a repository function skips most of that. So FitTrack tests the app the way clients hit it — through the ASGI app itself. httpx.AsyncClient with ASGITransport(app=app) sends real requests into FastAPI in the same process, no uvicorn and no open port. It’s as fast as a unit test but covers routing, dependency injection, Pydantic validation, and serialization in one shot.

The database is where async test setups usually go wrong. FitTrack’s endpoints are genuinely async (async SQLAlchemy, asyncpg), so the tests are async too — that’s what pytest-asyncio is for. And rather than mock the database, the tests run against real Postgres (the same local Supabase instance from Supabase Foundation →), because that’s the only way to catch a broken relationship, a missing cascade, or a bad aggregate. To keep that from turning into a pile of leftover rows, the session fixture wraps each test in a transaction it always rolls back. Nothing a test writes survives it, so tests never see each other’s data and order never matters.

The last problem is auth. Every protected route depends on get_current_user, which decodes a Supabase JWT — a real token means a real Supabase session, which has no place in a test. FastAPI’s answer is app.dependency_overrides: swap get_current_user for a function that just returns a fake user id. The routers can’t tell the difference — they call the dependency and get a user id — so every protected endpoint is reachable without a single token. Overriding get_session the same way is what points the app at the test transaction instead of a fresh one.

In-process httpx.AsyncClient (ASGITransport) vs. spinning up a live uvicorn server and hitting it over HTTP

  • Pros: no port to bind, no server process to start and reap, no startup race to wait on; requests run in the test’s own event loop so a failure gives you a normal Python traceback into your app code; and it’s fast enough to run on every save.
  • Cons: it doesn’t exercise the real network stack or the production ASGI server, so a Uvicorn-specific or proxy-specific issue won’t show up here — that’s what the deploy smoke test in Deployment → is for.

A real-Postgres transaction-rollback fixture vs. mocking the database (or using SQLite in-memory)

  • Pros: tests run against the exact engine production uses, so numeric, uuid, cascading deletes, and SQL aggregates all behave for real; rollback keeps every test isolated and leaves nothing behind; and there’s no mock to drift out of sync with the schema.
  • Cons: the suite needs a running Postgres, so it’s not zero-setup and it’s a touch slower than an in-memory fake — a fair price for testing the database you actually ship, and local Supabase already gives you the instance.
Terminal window
uv add --dev pytest pytest-asyncio httpx

--dev records these under [dependency-groups] dev in pyproject.toml, so they install for development and CI but never ship in the production image. (httpx is already present transitively via fastapi[standard]; adding it explicitly makes the test dependency intentional.)

2. api/pyproject.toml — configure pytest

Section titled “2. api/pyproject.toml — configure pytest”

Add a pytest section so async tests run without decorating every one of them:

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

asyncio_mode = "auto" tells pytest-asyncio to treat every async def test_* as a coroutine test — no @pytest.mark.asyncio on each function. testpaths keeps collection pointed at the tests/ directory.

Prefer a separate pytest.ini?

The same config works as a standalone api/pytest.ini — the section is just [pytest] instead of [tool.pytest.ini_options]:

[pytest]
asyncio_mode = auto
testpaths = tests

Keeping it in pyproject.toml means one fewer file; either is fine.

This is the whole test harness: the engine, the rollback-per-test session, the dependency overrides, and the client. Every test file gets these fixtures for free.

api/tests/conftest.py
import os
from uuid import UUID
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.auth import get_current_user
from app.db import get_session
from app.main import app
from app.models import Base, Profile
# A dedicated test database URL, defaulting to the local Supabase Postgres.
# Point this at a throwaway database in CI so a test run can never touch dev data.
TEST_DATABASE_URL = os.environ.get(
"TEST_DATABASE_URL",
"postgresql+asyncpg://postgres:postgres@127.0.0.1:54322/postgres",
)
# The fake user every test authenticates as. It's a fixed UUID so assertions
# on `user_id` / `created_by` are predictable.
FAKE_USER_ID = UUID("00000000-0000-0000-0000-000000000001")
@pytest_asyncio.fixture(scope="session")
async def engine():
"""One async engine for the whole test session; ensure the schema exists."""
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn:
# Local Supabase already ran the migrations, so the tables exist. This
# create_all is a harmless no-op there and makes the suite runnable
# against a bare Postgres too.
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def session(engine):
"""A DB session wrapped in a transaction that is always rolled back.
Nothing a test writes is committed, so every test starts from the same
state and tests can't leak into each other.
"""
connection = await engine.connect()
transaction = await connection.begin()
Session = async_sessionmaker(bind=connection, expire_on_commit=False)
async with Session() as session:
# The fake user needs a profiles row: workouts.user_id and
# user-created exercises.created_by both FK to profiles(id). flush()
# (not commit) keeps it inside the transaction we roll back.
session.add(Profile(id=FAKE_USER_ID, display_name="Test User"))
await session.flush()
yield session
await transaction.rollback()
await connection.close()
@pytest_asyncio.fixture
async def client(session):
"""An AsyncClient bound to the app, with auth and the DB session overridden."""
async def override_get_session():
# Hand the app the *same* transactional session the test uses, so the
# request and the test see one consistent, rolled-back transaction.
yield session
# No Supabase token needed: get_current_user just returns the fake user id.
app.dependency_overrides[get_session] = override_get_session
app.dependency_overrides[get_current_user] = lambda: FAKE_USER_ID
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
yield client
app.dependency_overrides.clear()

The override of get_current_user is the key move: because app.dependency_overrides[get_current_user] returns FAKE_USER_ID, every route that does Depends(get_current_user) receives that id and never touches PyJWT or Supabase — the Auth (Supabase JWT) → machinery is bypassed in tests by design.

4. api/tests/test_exercises.py — the exercises endpoints

Section titled “4. api/tests/test_exercises.py — the exercises endpoints”

Arrange the input, act through the client, assert on the response. The parametrized test is the “table” — one row per case, one assertion body.

api/tests/test_exercises.py
import pytest
from httpx import AsyncClient
async def test_create_then_get_exercise(client: AsyncClient):
# Act: create an exercise.
resp = await client.post(
"/exercises",
json={"name": "Back Squat", "muscle_group": "legs", "is_public": True},
)
assert resp.status_code == 201
created = resp.json()
assert created["name"] == "Back Squat"
assert created["muscle_group"] == "legs"
# Assert: it's readable by id.
resp = await client.get(f"/exercises/{created['id']}")
assert resp.status_code == 200
assert resp.json()["id"] == created["id"]
async def test_list_returns_public_and_own(client: AsyncClient):
# Two exercises the current user created; both should come back in the list.
await client.post(
"/exercises",
json={"name": "Deadlift", "muscle_group": "back", "is_public": True},
)
await client.post(
"/exercises",
json={"name": "My Cable Row", "muscle_group": "back", "is_public": False},
)
resp = await client.get("/exercises")
assert resp.status_code == 200
names = {e["name"] for e in resp.json()}
assert {"Deadlift", "My Cable Row"} <= names
# The "table": each row is (payload, expected 422 field). Invalid bodies must
# be rejected by Pydantic validation before they reach the database.
@pytest.mark.parametrize(
"payload",
[
{"muscle_group": "legs"}, # missing name
{"name": "No Group"}, # missing muscle_group
{"name": "", "muscle_group": "legs"}, # empty name
],
)
async def test_create_rejects_invalid_body(client: AsyncClient, payload: dict):
resp = await client.post("/exercises", json=payload)
assert resp.status_code == 422

5. api/tests/test_workouts.py — logging a session

Section titled “5. api/tests/test_workouts.py — logging a session”

POST /workouts is the interesting one: it writes a workout and its sets in a single transaction. The test creates an exercise to reference, logs a workout with two sets, then reads the history back.

api/tests/test_workouts.py
from httpx import AsyncClient
async def _create_exercise(client: AsyncClient) -> str:
resp = await client.post(
"/exercises",
json={"name": "Bench Press", "muscle_group": "chest", "is_public": True},
)
assert resp.status_code == 201
return resp.json()["id"]
async def test_log_workout_persists_its_sets(client: AsyncClient):
exercise_id = await _create_exercise(client)
# Act: log one session with two sets, in a single request body.
resp = await client.post(
"/workouts",
json={
"notes": "Push day",
"sets": [
{"exercise_id": exercise_id, "set_index": 0, "reps": 5, "weight_kg": 60.0},
{"exercise_id": exercise_id, "set_index": 1, "reps": 5, "weight_kg": 62.5},
],
},
)
assert resp.status_code == 201
workout = resp.json()
assert workout["notes"] == "Push day"
assert len(workout["sets"]) == 2
assert workout["sets"][0]["reps"] == 5
assert workout["sets"][1]["weight_kg"] == 62.5
async def test_history_lists_the_logged_workout(client: AsyncClient):
exercise_id = await _create_exercise(client)
await client.post(
"/workouts",
json={
"notes": "Leg day",
"sets": [
{"exercise_id": exercise_id, "set_index": 0, "reps": 8, "weight_kg": 80.0},
],
},
)
resp = await client.get("/workouts")
assert resp.status_code == 200
history = resp.json()
assert len(history) == 1
assert history[0]["notes"] == "Leg day"
async def test_history_is_scoped_to_the_current_user(client: AsyncClient):
# A fresh transaction (this test's rollback isolation) means no other
# user's workouts exist, and the override authenticates as FAKE_USER_ID,
# so history starts empty and only reflects what this user logs.
resp = await client.get("/workouts")
assert resp.status_code == 200
assert resp.json() == []

Make sure the local Supabase Postgres is up (from Supabase Foundation →):

Terminal window
supabase status

Then run the suite from the api/ directory:

Terminal window
uv run pytest -v
tests/test_exercises.py::test_create_then_get_exercise PASSED
tests/test_exercises.py::test_list_returns_public_and_own PASSED
tests/test_exercises.py::test_create_rejects_invalid_body[payload0] PASSED
tests/test_exercises.py::test_create_rejects_invalid_body[payload1] PASSED
tests/test_exercises.py::test_create_rejects_invalid_body[payload2] PASSED
tests/test_workouts.py::test_log_workout_persists_its_sets PASSED
tests/test_workouts.py::test_history_lists_the_logged_workout PASSED
tests/test_workouts.py::test_history_is_scoped_to_the_current_user PASSED
======================== 8 passed in 0.41s ========================

Now prove the isolation is real: run the whole suite twice in a row.

Terminal window
uv run pytest -q && uv run pytest -q

Both runs pass identically. If any test committed instead of rolling back, the second run would find leftover exercises and workouts and the assertions on counts would fail — a green second run is the proof that every transaction was rolled back.

Check your understanding:

  • Why does the suite use httpx.AsyncClient with ASGITransport(app=app) instead of starting uvicorn and calling it over http://localhost:8000? What does the in-process client still cover that a bare repository test would not?
  • The session fixture calls await transaction.rollback() and never commits. How does that keep two tests that both create an exercise from interfering, and why does test order stop mattering?
  • What does app.dependency_overrides[get_current_user] = lambda: FAKE_USER_ID replace, and why can a test hit a Depends(get_current_user)-protected route without ever creating a Supabase JWT?
  • The session fixture inserts a Profile row for FAKE_USER_ID with flush() before yielding. Why is that row needed before any workout can be logged, and why flush() rather than commit()?

The FitTrack backend now has a pytest suite that drives the real app: uv add --dev pytest pytest-asyncio httpx brought in the tools, asyncio_mode = "auto" in pyproject.toml let every async def test_* run without decoration, and conftest.py supplied the three fixtures everything rests on — an httpx.AsyncClient over ASGITransport, a Postgres session wrapped in a transaction that’s always rolled back, and app.dependency_overrides swapping get_current_user for a fake user id and get_session for the test transaction. On top of those, arranged and parametrized tests exercised the Exercises API → and the Workouts API → — creating, listing, validating, and logging a full session with its sets — and uv run pytest came back green twice in a row, proving the isolation holds. Next, Flutter widget tests → does the same for the app: a ProviderContainer test of the save path and a widget test of the logging screen, with a fake apiProvider standing in for the real backend.