FastAPI Database Testing with SQLAlchemy and Fixtures
Database testing is where most FastAPI applications get their testing strategy wrong. The common mistakes: using a shared test database that accumulates state, running migrations on every test, or — worst of all — hitting the production database in CI. This guide shows you how to test FastAPI applications backed by SQLAlchemy correctly, with real isolation, real queries, and real confidence.
The Core Problem: Test Isolation
Every database test needs to answer one question: what state is the database in when this test starts? If the answer is "it depends on what previous tests did," your test suite is broken. Tests will pass locally and fail in CI depending on execution order. You'll spend hours debugging failures that disappear when you run the test in isolation.
The solution is database isolation at the transaction level.
Per-Test Database Transactions with Rollback
The most efficient isolation strategy wraps each test in a transaction and rolls it back when the test finishes. No data ever actually commits, so the database stays clean:
# tests/conftest.py
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import (
create_async_engine,
AsyncSession,
async_sessionmaker,
)
from sqlalchemy.pool import NullPool
from app.models import Base
from app.dependencies import get_db
from app.main import app
# Use a real test database — not SQLite, which behaves differently from Postgres
DATABASE_URL = "postgresql+asyncpg://postgres:testpassword@localhost:5432/testdb"
@pytest_asyncio.fixture(scope="session")
async def test_engine():
"""Single engine for the entire test session."""
engine = create_async_engine(
DATABASE_URL,
poolclass=NullPool, # Disable connection pooling for tests
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture
async def db(test_engine):
"""
Per-test async session with transaction rollback.
The key: we use a nested transaction (SAVEPOINT) so that even if the
endpoint code commits, we can roll back the outer transaction.
"""
async with test_engine.connect() as conn:
await conn.begin()
# Start a savepoint that the endpoint code can commit against
# without actually committing to the database
async with conn.begin_nested() as savepoint:
session = AsyncSession(bind=conn, expire_on_commit=False)
session.sync_session.begin_nested()
yield session
await session.close()
await savepoint.rollback()
@pytest_asyncio.fixture
async def client(db):
"""HTTP client wired to the test session."""
from httpx import AsyncClient, ASGITransport
async def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver"
) as c:
yield c
app.dependency_overrides.clear()The SAVEPOINT trick is crucial. When your endpoint calls session.commit(), it commits the savepoint, not the outer transaction. The rollback at the end undoes everything, including what the endpoint thought it committed.
SQLAlchemy Async Session Fixtures
Different test scenarios need different session configurations. Here are the patterns you'll use most:
# tests/conftest.py (continued)
@pytest_asyncio.fixture
async def fresh_session(test_engine):
"""
A session that doesn't share state with the app's session.
Use this for verifying database state after an API call.
"""
Session = async_sessionmaker(test_engine, expire_on_commit=False)
async with Session() as session:
async with session.begin():
yield session
@pytest_asyncio.fixture
async def read_only_session(test_engine):
"""Read-only session for verifying state without modifying it."""
async with test_engine.connect() as conn:
await conn.execution_options(isolation_level="SERIALIZABLE")
session = AsyncSession(bind=conn)
yield session
await session.close()# tests/test_user_creation.py
from app.models import User, Profile
async def test_create_user_also_creates_profile(client, db):
response = await client.post("/users", json={
"email": "alice@example.com",
"name": "Alice",
"create_profile": True
})
assert response.status_code == 201
user_id = response.json()["id"]
# Verify both User and Profile exist in the same transaction
from sqlalchemy import select
user = await db.get(User, user_id)
assert user is not None
assert user.email == "alice@example.com"
result = await db.execute(
select(Profile).where(Profile.user_id == user_id)
)
profile = result.scalar_one_or_none()
assert profile is not None
async def test_user_creation_fails_on_duplicate_email(client, db):
# Pre-populate
user = User(email="existing@example.com", name="Existing")
db.add(user)
await db.flush() # flush but not commit — still in our test transaction
response = await client.post("/users", json={
"email": "existing@example.com",
"name": "Duplicate"
})
assert response.status_code == 409
assert "already exists" in response.json()["detail"]factory_boy with SQLAlchemy
Manually constructing test objects gets tedious. factory_boy lets you define factories that create realistic test data with sensible defaults that you override only when relevant:
pip install factory-boy faker# tests/factories.py
import factory
import factory.fuzzy
from factory.alchemy import SQLAlchemyModelFactory
from faker import Faker
from app.models import User, Post, Comment, Tag
fake = Faker()
class UserFactory(SQLAlchemyModelFactory):
class Meta:
model = User
sqlalchemy_session_persistence = "flush"
email = factory.LazyFunction(lambda: fake.unique.email())
name = factory.LazyFunction(fake.name)
is_active = True
is_admin = False
created_at = factory.LazyFunction(fake.date_time_this_year)
class AdminUserFactory(UserFactory):
is_admin = True
email = factory.LazyFunction(lambda: f"admin-{fake.unique.numerify('###')}@example.com")
class PostFactory(SQLAlchemyModelFactory):
class Meta:
model = Post
sqlalchemy_session_persistence = "flush"
title = factory.LazyFunction(lambda: fake.sentence(nb_words=6))
content = factory.LazyFunction(lambda: fake.paragraphs(nb=3, ext_word_list=None))
author = factory.SubFactory(UserFactory)
status = factory.fuzzy.FuzzyChoice(["draft", "published", "archived"])
view_count = factory.fuzzy.FuzzyInteger(0, 10000)
class CommentFactory(SQLAlchemyModelFactory):
class Meta:
model = Comment
sqlalchemy_session_persistence = "flush"
post = factory.SubFactory(PostFactory)
author = factory.SubFactory(UserFactory)
content = factory.LazyFunction(lambda: fake.paragraph())# tests/conftest.py (add this)
@pytest_asyncio.fixture(autouse=True)
async def bind_factories(db):
"""Bind all factories to the test session automatically."""
from tests.factories import UserFactory, PostFactory, CommentFactory
for factory_class in [UserFactory, PostFactory, CommentFactory]:
factory_class._meta.sqlalchemy_session = db
yield
for factory_class in [UserFactory, PostFactory, CommentFactory]:
factory_class._meta.sqlalchemy_session = None# tests/test_post_endpoints.py
from tests.factories import UserFactory, PostFactory, CommentFactory
async def test_get_posts_returns_only_published(client, db):
UserFactory.create() # author
author = UserFactory.create()
PostFactory.create_batch(5, status="published", author=author)
PostFactory.create_batch(3, status="draft", author=author)
PostFactory.create_batch(2, status="archived", author=author)
await db.flush()
response = await client.get("/posts?status=published")
assert response.status_code == 200
posts = response.json()["items"]
assert len(posts) == 5
assert all(p["status"] == "published" for p in posts)
async def test_post_comment_count_is_accurate(client, db):
post = PostFactory.create(status="published")
CommentFactory.create_batch(7, post=post)
await db.flush()
response = await client.get(f"/posts/{post.id}")
assert response.status_code == 200
assert response.json()["comment_count"] == 7
async def test_admin_can_see_draft_posts(client, db):
from tests.factories import AdminUserFactory
admin = AdminUserFactory.create()
PostFactory.create_batch(3, status="draft")
await db.flush()
# Override auth to return admin user
from app.dependencies import get_current_user
app.dependency_overrides[get_current_user] = lambda: admin
response = await client.get("/posts?status=draft")
assert response.status_code == 200
assert len(response.json()["items"]) == 3
from app.main import app
app.dependency_overrides.clear()Testing Migrations
Alembic migrations need their own test strategy. You want to verify migrations are reversible and that data survives them:
# tests/test_migrations.py
import pytest
from alembic.config import Config
from alembic import command
from sqlalchemy import create_engine, text, inspect
SYNC_DATABASE_URL = "postgresql://postgres:testpassword@localhost:5432/migration_test_db"
@pytest.fixture(scope="module")
def migration_engine():
engine = create_engine(SYNC_DATABASE_URL)
yield engine
engine.dispose()
@pytest.fixture(scope="module")
def alembic_config():
cfg = Config("alembic.ini")
cfg.set_main_option("sqlalchemy.url", SYNC_DATABASE_URL)
return cfg
def test_migrations_run_clean(migration_engine, alembic_config):
"""Verify all migrations apply without error."""
# Start from scratch
with migration_engine.connect() as conn:
conn.execute(text("DROP SCHEMA public CASCADE"))
conn.execute(text("CREATE SCHEMA public"))
conn.commit()
command.upgrade(alembic_config, "head")
# Verify final schema matches our models
inspector = inspect(migration_engine)
tables = inspector.get_table_names()
assert "users" in tables
assert "posts" in tables
assert "comments" in tables
def test_migrations_are_reversible(migration_engine, alembic_config):
"""Verify we can downgrade all the way back."""
command.upgrade(alembic_config, "head")
command.downgrade(alembic_config, "base")
inspector = inspect(migration_engine)
tables = inspector.get_table_names()
# After full downgrade, no app tables should exist
assert "users" not in tables
def test_data_survives_migration(migration_engine, alembic_config):
"""Upgrade to the version before a migration, insert data, then upgrade."""
# Upgrade to one revision before the current head
command.upgrade(alembic_config, "head-1")
with migration_engine.connect() as conn:
conn.execute(text(
"INSERT INTO users (email, name) VALUES ('test@example.com', 'Test User')"
))
conn.commit()
# Now apply the final migration
command.upgrade(alembic_config, "head")
with migration_engine.connect() as conn:
result = conn.execute(
text("SELECT email, name FROM users WHERE email = 'test@example.com'")
)
row = result.fetchone()
assert row is not None
assert row.name == "Test User"Testing Complex Queries
Repository methods with complex SQLAlchemy queries need unit tests that verify the query logic independently of the HTTP layer:
# app/repositories/post_repository.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_, desc
from sqlalchemy.orm import selectinload
from app.models import Post, User, Comment, Tag, post_tags
class PostRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def get_trending(
self,
limit: int = 10,
min_views: int = 100,
days: int = 7
) -> list[Post]:
from datetime import datetime, timedelta
since = datetime.utcnow() - timedelta(days=days)
stmt = (
select(Post)
.options(selectinload(Post.author), selectinload(Post.tags))
.join(Post.author)
.where(
and_(
Post.status == "published",
Post.published_at >= since,
Post.view_count >= min_views,
)
)
.order_by(desc(Post.view_count))
.limit(limit)
)
result = await self.session.execute(stmt)
return result.scalars().unique().all()
async def search(
self,
query: str,
tags: list[str] | None = None,
author_id: int | None = None,
) -> list[Post]:
stmt = (
select(Post)
.options(selectinload(Post.tags))
.where(Post.status == "published")
)
if query:
stmt = stmt.where(
or_(
Post.title.ilike(f"%{query}%"),
Post.content.ilike(f"%{query}%"),
)
)
if tags:
stmt = stmt.join(post_tags).join(Tag).where(Tag.name.in_(tags))
if author_id:
stmt = stmt.where(Post.author_id == author_id)
result = await self.session.execute(stmt)
return result.scalars().unique().all()# tests/test_post_repository.py
import pytest
import pytest_asyncio
from datetime import datetime, timedelta
from app.repositories.post_repository import PostRepository
from tests.factories import PostFactory, UserFactory, TagFactory
@pytest_asyncio.fixture
async def repo(db):
return PostRepository(db)
async def test_get_trending_filters_by_min_views(repo, db):
PostFactory.create_batch(3, status="published", view_count=50) # below threshold
PostFactory.create_batch(2, status="published", view_count=500) # above threshold
await db.flush()
results = await repo.get_trending(min_views=100)
assert len(results) == 2
assert all(p.view_count >= 100 for p in results)
async def test_get_trending_excludes_old_posts(repo, db):
old_post = PostFactory.create(
status="published",
view_count=9999,
published_at=datetime.utcnow() - timedelta(days=30)
)
recent_post = PostFactory.create(
status="published",
view_count=200,
published_at=datetime.utcnow() - timedelta(hours=1)
)
await db.flush()
results = await repo.get_trending(days=7, min_views=100)
ids = [p.id for p in results]
assert old_post.id not in ids
assert recent_post.id in ids
async def test_search_matches_title_and_content(repo, db):
PostFactory.create(
status="published",
title="Python Testing Guide",
content="Learn to test your code."
)
PostFactory.create(
status="published",
title="FastAPI Tutorial",
content="This guide covers Python web development."
)
PostFactory.create(
status="published",
title="Unrelated Post",
content="Nothing to do with testing."
)
await db.flush()
results = await repo.search("Python")
assert len(results) == 2
async def test_search_by_tag_filters_correctly(repo, db):
python_tag = TagFactory.create(name="python")
js_tag = TagFactory.create(name="javascript")
python_post = PostFactory.create(status="published", tags=[python_tag])
js_post = PostFactory.create(status="published", tags=[js_tag])
both_post = PostFactory.create(status="published", tags=[python_tag, js_tag])
await db.flush()
results = await repo.search("", tags=["python"])
ids = {p.id for p in results}
assert python_post.id in ids
assert both_post.id in ids
assert js_post.id not in idsIsolation Strategies for Different Test Types
Not all tests need the same isolation level. Here's a tiered approach:
# tests/conftest.py — isolation strategies
@pytest_asyncio.fixture(scope="session")
async def static_data(test_engine):
"""
Seed data that's inserted once and never modified.
For: read-only reference data, enum values, configuration.
"""
from app.models import Category
Session = async_sessionmaker(test_engine)
async with Session() as session:
async with session.begin():
categories = [
Category(name="Technology", slug="technology"),
Category(name="Science", slug="science"),
Category(name="Business", slug="business"),
]
session.add_all(categories)
yield
@pytest_asyncio.fixture(scope="function")
async def isolated_db(test_engine, static_data):
"""
Full isolation per test via transaction rollback.
For: write tests, tests that modify data.
"""
async with test_engine.connect() as conn:
transaction = await conn.begin()
session = AsyncSession(bind=conn, expire_on_commit=False)
yield session
await session.close()
await transaction.rollback()
@pytest.fixture(scope="class")
def shared_test_data(db):
"""
Shared setup across tests in a class.
For: tests that read the same pre-populated data.
Use only when you're certain tests won't modify the data.
"""
# Populated in conftest or a class-scoped fixture
pass# tests/test_category_endpoints.py
class TestCategoryEndpoints:
"""
These tests only read category data — safe to share across the class.
"""
async def test_list_categories(self, client, static_data):
response = await client.get("/categories")
assert response.status_code == 200
categories = response.json()
assert len(categories) == 3
slugs = {c["slug"] for c in categories}
assert "technology" in slugs
async def test_get_category_by_slug(self, client, static_data):
response = await client.get("/categories/science")
assert response.status_code == 200
assert response.json()["name"] == "Science"
async def test_nonexistent_category_returns_404(self, client):
response = await client.get("/categories/nonexistent")
assert response.status_code == 404Avoiding Common Pitfalls
Don't use SQLite for Postgres applications. SQLite has different behavior around constraints, concurrency, and data types. A test that passes with SQLite may fail with Postgres because SQLite silently ignores some constraint violations.
Don't use expire_on_commit=False globally. It masks bugs where your code accesses attributes after a session is closed. Use it only in test sessions where you genuinely need to access data after commit.
Don't forget await db.flush() after adding factory objects. Without a flush, the data isn't written to the database connection, and your endpoint may not see it.
Always check that your isolation actually works. Write a simple test that adds data, then write a second test that verifies the data isn't there. If the second test fails, your isolation is broken.
# tests/test_isolation_sanity.py
async def test_isolation_part_1(client, db):
from tests.factories import UserFactory
UserFactory.create(email="isolation-test@example.com")
await db.flush()
response = await client.get("/users?email=isolation-test@example.com")
assert len(response.json()) == 1 # visible within this test
async def test_isolation_part_2(client, db):
# This should see 0 users — the previous test's data was rolled back
from sqlalchemy import select, func
from app.models import User
result = await db.execute(select(func.count(User.id)))
count = result.scalar()
assert count == 0, f"Isolation failed: {count} users found from previous test"This sanity check should be the first test you write when setting up database testing. If it fails, fix your isolation before writing any other tests.