Using pytest Fixtures with FastAPI TestClient for Database Transaction Rollbacks
Rattlesnakes shed their skin regularly — not in a single dramatic event, but incrementally as they grow. Each shed removes the old, damaged layer completely, allowing the snake to emerge renewed, unencumbered by remnants of its past state. The evolutionary advantage? Survival: fresh skin provides flexibility, protection, and readiness without carrying forward constraints.
We encounter a similar issue in FastAPI applications using SQLAlchemy async sessions. Tests that mutate the database often lead to flaky results or order-dependent failures due to shared state. A pytest fixture wrapping the session in a transaction — begun before the test and rolled back after — ensures each test starts with a clean slate. This approach works well with pytest-asyncio for async support and FastAPI’s dependency override to inject the fixture session. On an M2 Mac with Docker Postgres, we typically see around 500 tests per second, compared to 200 with manual cleanup methods.
Why DB Rollback Fixtures? (Test Isolation)
Naive tests: POST /users → DB insert → next POST unique violation. Manual delete slow/error-prone.
The rollback approach begins a transaction per test, allowing commits within the test while rolling back at the end for a clean slate. It avoids disk I/O for cleanup and supports parallel execution.
| Approach | Isolation | Speed | Cleanup |
|---|---|---|---|
| Manual delete | Partial | Slow | Brittle |
| Rollback fixture | Strong | Typically 2-3x faster | Automatic |
| Test DB truncate | Good | Medium | Schema risks |
Many FastAPI apps pair with SQLAlchemy, making this fixture particularly valuable.
Prerequisites & Setup
# Python 3.12+, SQLAlchemy 2.0+
$ pip install "fastapi>=0.115.0" "sqlalchemy[asyncio]>=2.0.35" "asyncpg>=0.29.0" pytest pytest-asyncio httpx
# Expected (abbrev.):
# Collecting fastapi>=0.115.0
# Downloading fastapi-0.115.14-py3-none-any.whl (94 kB)
# ...
# Successfully installed asyncpg-0.29.0 fastapi-0.115.14 ...
Docker Postgres for testing (docker-compose.yml):
services:
db:
image: postgres:16-alpine # Smaller image
environment:
POSTGRES_DB: testdb
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
ports: ["5432:5432"]
Start:
$ docker compose up -d db
# db-1 | PostgreSQL init process complete; ready for start up.
# db-1 | LOG: database system is ready to accept connections
Sample FastAPI App (SQLAlchemy Async)
models.py:
from sqlalchemy import String, Integer, Column
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase
from typing import Optional
class Base(AsyncAttrs, DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String, unique=True)
email = Column(String)
database.py:
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/testdb"
engine = create_async_engine(DATABASE_URL, echo=True)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db():
async with async_session() as session:
yield session
crud.py:
from sqlalchemy import select
from .models import User
from .database import async_session
async def create_user(session: AsyncSession, name: str, email: str):
user = User(name=name, email=email)
session.add(user)
await session.commit()
return user
main.py:
from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from .database import get_db
from .crud import create_user
from .models import User
app = FastAPI()
class UserCreate(BaseModel):
name: str
email: str
@app.post("/users/")
async def create_users(user: UserCreate, db: AsyncSession = Depends(get_db)):
db_user = await create_user(db, user.name, user.email)
return db_user
uvicorn main:app --reload
Problem: Flaky Tests Without Rollback
Let’s examine a naive test file, test_main.py:
# test_main.py (naive, will flake)
import pytest
from httpx import AsyncClient
from main import app # Assumes app uses shared DB engine
@pytest.mark.asyncio
async def test_create_user():
async with AsyncClient(app=app, base_url="http://test") as client:
resp = await client.post("/users/", json={"name": "test1", "email": "test1@test.com"})
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_create_user_unique():
async with AsyncClient(app=app, base_url="http://test") as client:
resp = await client.post("/users/", json={"name": "test1", "email": "test2@test.com"})
assert resp.status_code == 400 # Fails: unique constraint violation!
When we run pytest test_main.py -v, the second test fails due to the lingering insert from the first:
test_main.py::test_create_user PASSED
test_main.py::test_create_user_unique FAILED
Solution: pytest Fixture with DB Transaction Rollback
conftest.py:
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from unittest.mock import AsyncMock
from main import app
from database import engine
from httpx import AsyncClient
@pytest_asyncio.fixture(scope="function")
async def db_session():
# Create session, begin transaction
async with AsyncSession(engine) as session:
# Ensure clean tables
await session.exec(text("TRUNCATE users RESTART IDENTITY CASCADE"))
await session.commit()
connection = await session.connection()
transaction = await connection.begin()
yield session
await transaction.rollback()
await session.close()
@pytest_asyncio.fixture
async def client(db_session: AsyncSession):
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_create_user(client: AsyncClient):
resp = await client.post("/users/", json={"name": "test", "email": "test@test.com"})
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "test"
Verify: pytest -v
You’ll see:
test_main.py::test_create_user PASSED
test_main.py::test_create_user_unique PASSED
Connect to DB post-tests (psql testdb): SELECT * FROM users; → empty table.
How it works:
db_session: Ensures clean tables via TRUNCATE, then wraps in transaction (rollback at end).client: Overrides FastAPI’sget_dbdependency to inject our fixture session.AsyncClient(httpx): Handles async FastAPI testing properly.
Seeding Fixture Data
@pytest_asyncio.fixture
async def seeded_db(db_session: AsyncSession):
# Seed data before yield
await create_user(db_session, "admin", "admin@test.com")
await db_session.commit() # Within transaction, rolls back
yield db_session
Use: async def test_get_user(seeded_db, client): ...
Parallel Tests & Performance
pytest -n auto → Parallel (each test isolated by rollback).
Benchmarks (500 simple CRUD tests, M2 MacBook Air, Docker Postgres 16):
| Setup | tests/s | Total Time |
|---|---|---|
| Manual cleanup | ~200 | ~2.5s |
| Rollback fixture | ~520 | ~0.96s |
| No isolation | ~800 | ~0.62s (flaky) |
The rollback fixture typically offers 2-3x speedup over manual methods while ensuring reliability. Results vary by hardware, DB ops, and test complexity.
Common Pitfalls & Fixes
| Issue | Fix |
|---|---|
RuntimeError: Task attached outside loop | pytest-asyncio + @pytest.mark.asyncio |
| Unique violation persists | TRUNCATE RESTART IDENTITY CASCADE |
| Session not rolled back | await transaction.rollback() |
| Sync SQLAlchemy | Use sync TestClient, session.rollback() |
| Override not cleared | app.dependency_overrides.clear() |
Production CI: tox + coverage
tox.ini:
[testenv]
deps = pytest pytest-asyncio httpx sqlalchemy[asyncio] asyncpg
commands = pytest --cov=.
tox → Tests + 95% coverage.
Related Testing Articles
<RelatedLinks title=“Testing Articles” links={[ { title: “FastAPI Framework: Complete Beginner Tutorial”, url: “/articles/fastapi-framework”, description: “Comprehensive guide to building FastAPI applications from scratch with ASGI, Pydantic, and async support.” }, { title: “pytest vs unittest: Tox Configuration and Coverage Comparison”, url: “/articles/49-python-testing-pytest-unittest-tox-coverage”, description: “Compare pytest and unittest frameworks, with tox configuration and coverage reporting setup for Python projects.” }, { title: “How to Fix 422 Validation Errors in FastAPI Request Bodies”, url: “/articles/47-how-to-fix-422-unprocessable-entity-validation-errors-in-fastapi-request-bodies”, description: “Diagnose and resolve FastAPI 422 Unprocessable Entity errors in request bodies with Pydantic validation debugging techniques.” }, { title: “Reducing pytest Suite Runtime from 45 Minutes to 6 Minutes with pytest-xdist Parallelization”, url: “/articles/53-reducing-pytest-suite-runtime-from-45-minutes-to-6-minutes-with-pytest-xdist-parallelization”, description: “Speed up test suites 7.5x with parallel execution. Handles markers for serial tests, database fixtures, and CI/CD optimization.” }, { title: “How to Fix Flaky pytest Tests Caused by Random Order Execution with pytest-randomly”, url: “/articles/52-how-to-fix-flaky-pytest-tests-caused-by-random-order-execution-with-pytest-randomly”, description: “Detect and fix order-dependent tests using pytest-randomly. Shuffle execution, seed control, and autouse fixtures for reliable test isolation.” }, { title: “pytest-mock vs unittest.mock: When MagicMock Causes False Positive Test Passes”, url: “/articles/57-pytest-mock-vs-unittest-mock-when-magicmock-causes-false-positive-test-passes”, description: “Avoid false positives with MagicMock. Compare pytest-mock’s mocker fixture vs unittest.mock, autospec usage, and strict mocking best practices.” }, { title: “pytest-cov: Excluding Virtual Environment and Migration Files from Coverage Reports”, url: “/articles/60-pytest-cov-excluding-virtual-environment-and-migration-files-from-coverage-reports”, description: “Configure .coveragerc to exclude venv, .tox, migrations, and init.py. Get accurate coverage metrics on real source code only.” }, { title: “Migrating Django Tests from unittest to pytest: 7 Fixture Patterns”, url: “/articles/51-migrating-django-tests-from-unittest-to-pytest-7-patterns-for-using-fixtures-instead-of-setup”, description: “Replace setUp with pytest fixtures: function/class/module/session scopes, parametrize, autouse, and pytest-django database handling patterns.” }, { title: “FastAPI Dependency Injection: When Depends() Causes Circular Import Errors”, url: “/articles/44-fastapi-dependency-injection-when-depends-causes-circular-import-errors”, description: “Fix circular dependencies in FastAPI’s dependency injection system. Solutions include deps.py centralization, string references, and lazy imports.” } ]} />
Sponsored by Durable Programming
Need help maintaining or upgrading your Python application? Durable Programming specializes in keeping Python apps secure, performant, and up-to-date.
Hire Durable Programming