Chapter 11: testing SQLAlchemy without mocks. Real DBs, per-test transactions, factories, async fixtures, CI integration.

Why real DBs

Mocking SQLAlchemy is tempting — and produces false confidence. Real DBs catch:

  • Constraint violations.
  • Type coercion.
  • Migration / model drift.
  • Index usage.
  • Transaction semantics.

Mocks pass; production fails. Always test against the real DB.

testcontainers

import pytest
from testcontainers.postgres import PostgresContainer

@pytest.fixture(scope="session")
def postgres():
    with PostgresContainer("postgres:17") as pg:
        yield pg

Spins up a Postgres in Docker for the test session. Clean; isolated; fast (~3s startup).

For MySQL / SQLite: similar containers exist.

Engine fixture

from sqlalchemy.ext.asyncio import create_async_engine

@pytest.fixture(scope="session")
async def engine(postgres):
    url = postgres.get_connection_url().replace("psycopg2", "asyncpg")
    engine = create_async_engine(url, echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()

Create schema once per session.

Per-test transactions

The fast pattern:

@pytest.fixture
async def db_session(engine):
    connection = await engine.connect()
    transaction = await connection.begin()
    async with AsyncSession(bind=connection, expire_on_commit=False) as session:
        nested = await session.begin_nested()
        
        @event.listens_for(session.sync_session, "after_transaction_end")
        def end_savepoint(session, transaction):
            nonlocal nested
            if not nested.is_active:
                nested = session.begin_nested()
        
        yield session
    
    await transaction.rollback()
    await connection.close()

Each test starts a savepoint. After the test: rollback. DB returns to clean state.

For simpler tests:

@pytest.fixture
async def db_session(engine):
    async with AsyncSession(engine, expire_on_commit=False) as session:
        yield session
        await session.rollback()

Less robust if test commits something; truncate tables between tests.

Factory boy

import factory

class UserFactory(factory.Factory):
    class Meta:
        model = User
    
    email = factory.Sequence(lambda n: f"user{n}@example.com")
    full_name = factory.Faker("name")
    is_active = True

class PostFactory(factory.Factory):
    class Meta:
        model = Post
    
    title = factory.Faker("sentence")
    body = factory.Faker("text")
    author = factory.SubFactory(UserFactory)
async def test_user_create(db_session):
    user = UserFactory.build()
    db_session.add(user)
    await db_session.commit()
    
    fetched = await db_session.get(User, user.id)
    assert fetched.email == user.email

build() creates; add() to insert. Fixtures stay short.

Pytest-asyncio / pytest-anyio

import pytest

@pytest.mark.asyncio
async def test_x(db_session):
    ...

Or with anyio:

@pytest.fixture(scope="session")
def anyio_backend():
    return "asyncio"

@pytest.mark.anyio
async def test_x(db_session):
    ...

Both work. anyio is more common in modern FastAPI projects.

Testing relationships

async def test_posts_loaded(db_session):
    user = UserFactory.build()
    user.posts = [PostFactory.build() for _ in range(3)]
    db_session.add(user)
    await db_session.commit()
    
    stmt = select(User).options(selectinload(User.posts)).where(User.id == user.id)
    result = await db_session.scalar(stmt)
    assert len(result.posts) == 3

Testing constraints

async def test_email_unique(db_session):
    db_session.add(UserFactory.build(email="[email protected]"))
    await db_session.commit()
    
    db_session.add(UserFactory.build(email="[email protected]"))
    with pytest.raises(IntegrityError):
        await db_session.commit()

Verifies the DB constraint actually fires.

Testing migrations

def test_no_drift(engine):
    from alembic.autogenerate import compare_metadata
    from alembic.migration import MigrationContext
    
    with engine.sync_engine.connect() as conn:
        ctx = MigrationContext.configure(conn)
        diff = compare_metadata(ctx, Base.metadata)
        assert diff == [], f"Drift: {diff}"

Catches forgotten migrations.

For migration up/down:

def test_migrations(postgres):
    cfg = Config("alembic.ini")
    cfg.set_main_option("sqlalchemy.url", postgres.get_connection_url())
    
    command.upgrade(cfg, "head")
    command.downgrade(cfg, "base")
    command.upgrade(cfg, "head")

Performance tests

For critical queries, test query count:

async def test_no_n_plus_1(db_session):
    [UserFactory.build() for _ in range(10)]
    await db_session.commit()
    
    queries = []
    @event.listens_for(db_session.sync_session, "do_orm_execute")
    def collect(orm_execute_state):
        queries.append(orm_execute_state.statement)
    
    stmt = select(User).options(selectinload(User.posts))
    users = (await db_session.execute(stmt)).scalars().all()
    for u in users:
        _ = u.posts  # access
    
    assert len(queries) == 2  # users + posts (no N+1)

Tests that selectinload is being used.

Fixture composition

@pytest.fixture
async def admin_user(db_session):
    user = UserFactory.build(role="admin")
    db_session.add(user)
    await db_session.commit()
    return user

@pytest.fixture
async def auth_client(client, admin_user):
    token = make_token(admin_user.id)
    client.headers["Authorization"] = f"Bearer {token}"
    return client

async def test_admin_endpoint(auth_client):
    r = await auth_client.get("/admin/users")
    assert r.status_code == 200

Compose fixtures up the stack.

CI integration

services:
  postgres:
    image: postgres:17
    env:
      POSTGRES_PASSWORD: test
    ports: ["5432:5432"]
    options: >-
      --health-cmd pg_isready
      --health-interval 10s

Or testcontainers does this automatically.

Cross-DB testing

For DB-agnostic libraries, parametrize:

@pytest.fixture(params=["postgres", "mysql", "sqlite"])
def engine(request):
    if request.param == "postgres":
        with PostgresContainer() as pg:
            yield create_async_engine(pg.get_connection_url())
    elif ...

Run tests against each. Slower; catches dialect bugs.

Common mistakes

1. Mocking the session

session = AsyncMock()
session.get.return_value = User(...)

Tests pass; SQL never runs; constraint bugs ship. Use real DB.

2. Shared state across tests

Test A inserts; test B sees row; test order matters. Use per-test transactions.

3. Slow test setup

Spin up Postgres for each test → minutes. Session-scoped fixture; per-test transaction.

4. Not testing migrations

Migration breaks; CI passes; deploy fails. Run migrations in CI.

5. SQLite for tests when prod is Postgres

Different dialects; different behavior. Test against the prod DB.

What’s next

Chapter 12: Production patterns.

Read this next


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 .