If a FastAPI app has tests, they tend to fall into one of two camps:
- A few smoke tests that hit the running service over HTTP. Slow, brittle, and don’t run in CI.
- Mocked-to-the-gills unit tests that pass even when the real DB query is broken.
Neither is great. The good middle ground — fast, isolated tests that run the real app code against a real database with proper rollback between tests — is genuinely simple once you’ve seen the pattern.
This post is that pattern.
What we’ll cover
- Pytest setup that works with FastAPI’s async stack.
- Using
httpx.AsyncClientto hit the app in-process (no real network). - Database isolation: each test gets its own transaction that rolls back at the end.
- Fixtures for users, tokens, and authenticated clients.
- The structure that scales as your test suite grows.
We’ll build on the FastAPI + SQLAlchemy stack from earlier in the series.
Install the testing stack
uv add --dev pytest pytest-asyncio httpx
That’s it. We don’t need pytest-asyncio to do anything fancy — just to handle async def test functions.
Pytest configuration
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
testpaths = ["tests"]
asyncio_mode = "auto" means every async def test_... is treated as an async test without needing @pytest.mark.asyncio everywhere. Cleaner.
A separate test database
You never want tests to run against your dev or prod database. Spin up a separate one — same Postgres instance, separate database name:
CREATE DATABASE tasksdb_test;
ALTER DATABASE tasksdb_test OWNER TO tasksuser;
In your test environment, point at it:
# .env.test
DATABASE_URL=postgresql+asyncpg://tasksuser:tasksdbpass@localhost:5432/tasksdb_test
SECRET_KEY=test-secret-not-for-production
The fixture below will load this env file before any test runs.
The conftest.py — the heart of test isolation
# tests/conftest.py
import asyncio
import os
import pytest
import pytest_asyncio
from collections.abc import AsyncGenerator
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
# Load test env BEFORE importing app modules
from dotenv import load_dotenv
load_dotenv(".env.test", override=True)
from app.core.config import get_settings # noqa: E402
from app.core.database import Base # noqa: E402
from app.api.deps import get_db # noqa: E402
from app.main import app # noqa: E402
settings = get_settings()
test_engine = create_async_engine(settings.database_url, future=True)
TestSessionLocal = async_sessionmaker(test_engine, expire_on_commit=False, class_=AsyncSession)
@pytest_asyncio.fixture(scope="session")
async def setup_db():
"""Create all tables once for the test session."""
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def db_session(setup_db) -> AsyncGenerator[AsyncSession, None]:
"""A fresh session per test, wrapped in a transaction that rolls back."""
async with test_engine.connect() as conn:
trans = await conn.begin()
async with TestSessionLocal(bind=conn) as session:
yield session
await trans.rollback()
@pytest_asyncio.fixture
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
"""An HTTP client that talks to the app in-process and shares the test session."""
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
There’s a lot here — let’s unpack it.
setup_db (session-scoped)
Runs once at the start of the session. Creates all tables. At the end, drops them. This is what gives every test run a clean schema.
If your project uses Alembic migrations, you can swap Base.metadata.create_all for command.upgrade(alembic_cfg, "head") — though the speed cost is noticeable.
db_session (per-test)
The clever bit: each test opens a connection-scoped transaction. The session is bound to that connection. When the test ends, we roll back the entire transaction, including any inserts the test made. The next test sees an empty schema again — without having to drop and recreate tables.
This is dramatically faster than TRUNCATE-ing tables between tests, and it gives you perfect isolation.
client (per-test)
We override FastAPI’s get_db dependency to return our session — the one inside the rolling-back transaction. This way the app and the test see the same data.
ASGITransport(app=app) lets httpx.AsyncClient call FastAPI directly, in-process, with no real network. Fast and reliable.
app.dependency_overrides.clear() at the end is critical — without it, overrides leak between tests.
A simple smoke test
# tests/test_health.py
import pytest
@pytest.mark.asyncio
async def test_health(client):
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
Run it:
pytest -v
If this works, your test plumbing is correct. Don’t move on until it does.
Testing CRUD endpoints
# tests/test_tasks.py
import pytest
@pytest.mark.asyncio
async def test_create_task(client):
response = await client.post("/tasks/", json={"title": "Buy milk"})
assert response.status_code == 201
body = response.json()
assert body["title"] == "Buy milk"
assert body["completed"] is False
assert "id" in body
@pytest.mark.asyncio
async def test_list_tasks_empty(client):
response = await client.get("/tasks/")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
async def test_create_then_get(client):
create = await client.post("/tasks/", json={"title": "Walk dog"})
task_id = create.json()["id"]
fetch = await client.get(f"/tasks/{task_id}")
assert fetch.status_code == 200
assert fetch.json()["title"] == "Walk dog"
@pytest.mark.asyncio
async def test_update_task(client):
create = await client.post("/tasks/", json={"title": "Initial"})
task_id = create.json()["id"]
update = await client.patch(f"/tasks/{task_id}", json={"title": "Updated"})
assert update.status_code == 200
assert update.json()["title"] == "Updated"
@pytest.mark.asyncio
async def test_delete_task(client):
create = await client.post("/tasks/", json={"title": "Will be deleted"})
task_id = create.json()["id"]
delete = await client.delete(f"/tasks/{task_id}")
assert delete.status_code == 204
fetch = await client.get(f"/tasks/{task_id}")
assert fetch.status_code == 404
@pytest.mark.asyncio
async def test_get_nonexistent_returns_404(client):
response = await client.get("/tasks/999999")
assert response.status_code == 404
Each test is fully independent. test_list_tasks_empty doesn’t see the tasks test_create_task made — because that test’s transaction rolled back.
Authentication fixtures
For protected endpoints, build helper fixtures that create a user and return an authenticated client:
# tests/conftest.py (additions)
from app.core.security import create_access_token, hash_password
from app.models.user import User
@pytest_asyncio.fixture
async def test_user(db_session) -> User:
user = User(
username="alzy",
email="[email protected]",
hashed_password=hash_password("strongpass123"),
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest_asyncio.fixture
async def auth_client(client, test_user) -> AsyncClient:
token = create_access_token(test_user.id)
client.headers.update({"Authorization": f"Bearer {token}"})
return client
Now any test that needs an authenticated request just asks for auth_client:
# tests/test_me.py
@pytest.mark.asyncio
async def test_me_authenticated(auth_client, test_user):
response = await auth_client.get("/me")
assert response.status_code == 200
assert response.json()["username"] == test_user.username
@pytest.mark.asyncio
async def test_me_unauthenticated(client):
response = await client.get("/me")
assert response.status_code == 401
The fixture system means you compose these — auth_client builds on client which builds on db_session. Each piece is small, named, reusable.
Parameterized tests
For testing many inputs against the same logic:
import pytest
@pytest.mark.parametrize(
"title,expected_status",
[
("Valid title", 201),
("", 422),
("A" * 201, 422),
("Another good one", 201),
],
)
@pytest.mark.asyncio
async def test_create_task_validation(client, title, expected_status):
response = await client.post("/tasks/", json={"title": title})
assert response.status_code == expected_status
@pytest.mark.parametrize runs the test once per row, with each row appearing as its own test in the report.
Speeding things up
A few habits keep the suite fast as it grows:
- Use connection-scoped transactions (the pattern above) — far faster than
TRUNCATE. - Don’t actually call external APIs in tests — mock
httpxcalls withrespxor similar. - Don’t sleep. If a test does
await asyncio.sleep(0.1), find a way to avoid it. - Use
pytest -xto stop at the first failure during development. - Use
pytest-xdistto run tests in parallel (pytest -n auto). - Profile slow tests with
pytest --durations=10.
What about mocking?
Mock at the boundary of your system:
- External HTTP APIs → mock the HTTP client.
- Email/SMS providers → mock the send function.
- Time-based behavior → use
freezegunto freeze the clock.
Don’t mock your ORM, your routers, or your serializers. If you find yourself mocking a SQLAlchemy session, you’ve gone too deep — your test isn’t testing your app anymore.
What about CI?
GitHub Actions, GitLab CI, etc. — the workflow is roughly:
- Spin up a Postgres service container.
- Set
DATABASE_URLto point at it. - Run
pytest.
Example GitHub Actions workflow:
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: tasksuser
POSTGRES_PASSWORD: tasksdbpass
POSTGRES_DB: tasksdb_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --dev
- env:
DATABASE_URL: postgresql+asyncpg://tasksuser:tasksdbpass@localhost:5432/tasksdb_test
SECRET_KEY: ci-secret-not-for-prod
run: uv run pytest
That’s a complete CI pipeline for a FastAPI app.
Conclusion
Good tests for FastAPI aren’t hard — they just require getting the plumbing right once. The pattern in this post (per-test transactions, in-process HTTP client, dependency-overridden DB session, layered fixtures) scales from a single-file app to a project with thousands of tests without changes.
Tests that run fast and tell you the truth are one of the best investments you can make in any codebase. Make them easy to write, and you’ll write more of them.
The full FastAPI series:
Happy testing!
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .