The go-to resource for upgrading Python, Django, Flask, and your dependencies.

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.

ApproachIsolationSpeedCleanup
Manual deletePartialSlowBrittle
Rollback fixtureStrongTypically 2-3x fasterAutomatic
Test DB truncateGoodMediumSchema 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’s get_db dependency 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):

Setuptests/sTotal 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

IssueFix
RuntimeError: Task attached outside looppytest-asyncio + @pytest.mark.asyncio
Unique violation persistsTRUNCATE RESTART IDENTITY CASCADE
Session not rolled backawait transaction.rollback()
Sync SQLAlchemyUse sync TestClient, session.rollback()
Override not clearedapp.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.

<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