← Back to DevBytes

Testing SQLAlchemy Applications: Unit Tests to Integration

Testing SQLAlchemy Applications: From Unit Tests to Integration

Testing database-driven applications is notoriously tricky. Databases introduce state, side effects, and slow I/O that can make tests brittle and hard to maintain. SQLAlchemy, being the most popular Python ORM, gives you several hooks and patterns to make testing manageable. This tutorial walks you through the full spectrum — from pure unit tests that mock the ORM, to integration tests that hit a real database, to end-to-end tests that exercise your entire stack.

Why Testing SQLAlchemy Matters

When your application logic is tightly coupled to SQLAlchemy queries, bugs can hide in subtle places: a missing join, a lazy-load that triggers an extra query, a transaction that never commits, or a constraint that silently fails. Without a solid test suite, these issues only surface in production. A well-structured test strategy lets you catch them early, refactor confidently, and document expected behavior.

There are three broad categories of tests you should consider:

Setting Up the Project

Let's start with a small but realistic example. We'll build a simple blog application with users and posts, then test it layer by layer.

First, install the dependencies:

pip install sqlalchemy pytest pytest-asyncio psycopg2-binary

Here is our base model and session configuration:

# app/models.py
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
from sqlalchemy.orm import declarative_base, relationship

Base = declarative_base()


class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)
    username = Column(String(50), unique=True, nullable=False)
    email = Column(String(120), unique=True, nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow)

    posts = relationship("Post", back_populates="author", cascade="all, delete-orphan")


class Post(Base):
    __tablename__ = "posts"

    id = Column(Integer, primary_key=True)
    title = Column(String(200), nullable=False)
    body = Column(Text, nullable=False)
    author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow)

    author = relationship("User", back_populates="posts")
# app/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from contextlib import contextmanager

DATABASE_URL = "postgresql://localhost/blogdb"

engine = create_engine(DATABASE_URL, echo=False)
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)


@contextmanager
def get_session() -> Session:
    session = SessionLocal()
    try:
        yield session
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()

Now let's add a service layer that contains the business logic we want to test:

# app/services.py
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import User, Post


def create_user(session: Session, username: str, email: str) -> User:
    if not username or not email:
        raise ValueError("username and email are required")
    user = User(username=username, email=email)
    session.add(user)
    session.flush()
    return user


def create_post(session: Session, author_id: int, title: str, body: str) -> Post:
    author = session.get(User, author_id)
    if author is None:
        raise ValueError(f"User {author_id} not found")
    post = Post(title=title, body=body, author=author)
    session.add(post)
    session.flush()
    return post


def get_posts_by_user(session: Session, username: str) -> list[Post]:
    stmt = select(Post).join(User).where(User.username == username)
    return list(session.scalars(stmt).all())

Unit Tests: Isolating Business Logic

Unit tests should be fast and deterministic. The goal is to test the logic in your service functions without touching a real database. There are two common approaches: mocking the session, or using an in-memory SQLite database. We'll cover both.

Approach 1: Mocking the Session

Mocking is useful when you want to verify that your code calls SQLAlchemy correctly without actually executing queries. This is fast but can be brittle — if you mock too much, your tests pass even when the real query is wrong.

# tests/test_unit_mock.py
from unittest.mock import MagicMock
import pytest
from app.services import create_user, create_post
from app.models import User, Post


def test_create_user_calls_add_and_flush():
    session = MagicMock()
    user = create_user(session, username="alice", email="alice@example.com")

    assert user.username == "alice"
    assert user.email == "alice@example.com"
    session.add.assert_called_once()
    session.flush.assert_called_once()


def test_create_user_rejects_empty_username():
    session = MagicMock()
    with pytest.raises(ValueError, match="required"):
        create_user(session, username="", email="alice@example.com")
    session.add.assert_not_called()


def test_create_post_raises_when_author_missing():
    session = MagicMock()
    session.get.return_value = None
    with pytest.raises(ValueError, match="not found"):
        create_post(session, author_id=999, title="Hello", body="World")

Notice that these tests run in milliseconds and require no database setup. The tradeoff is that they don't catch issues like a wrong column name or a malformed query.

Approach 2: In-Memory SQLite

A better balance is to use an in-memory SQLite database. This gives you real query execution without the overhead of a persistent database. SQLAlchemy makes this straightforward with StaticPool, which keeps the same in-memory database across all connections in a session.

# tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.models import Base


@pytest.fixture
def db_session():
    engine = create_engine(
        "sqlite://",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    Base.metadata.create_all(engine)
    TestingSessionLocal = sessionmaker(bind=engine)
    session = TestingSessionLocal()
    try:
        yield session
    finally:
        session.close()
        Base.metadata.drop_all(engine)
# tests/test_unit_sqlite.py
import pytest
from app.services import create_user, create_post, get_posts_by_user


def test_create_user_persists(db_session):
    user = create_user(db_session, username="bob", email="bob@example.com")
    db_session.commit()

    fetched = db_session.get(type(user), user.id)
    assert fetched is not None
    assert fetched.username == "bob"


def test_create_post_links_author(db_session):
    user = create_user(db_session, username="carol", email="carol@example.com")
    db_session.flush()
    post = create_post(db_session, author_id=user.id, title="Hi", body="Body")
    db_session.commit()

    assert post.author_id == user.id
    assert post.author.username == "carol"


def test_get_posts_by_user_returns_only_their_posts(db_session):
    alice = create_user(db_session, username="alice", email="a@e.com")
    dave = create_user(db_session, username="dave", email="d@e.com")
    db_session.flush()
    create_post(db_session, alice.id, "A1", "body")
    create_post(db_session, alice.id, "A2", "body")
    create_post(db_session, dave.id, "D1", "body")
    db_session.commit()

    posts = get_posts_by_user(db_session, "alice")
    assert len(posts) == 2
    assert all(p.author_id == alice.id for p in posts)


def test_unique_constraint_violation(db_session):
    create_user(db_session, username="eve", email="eve@e.com")
    db_session.commit()
    with pytest.raises(Exception):
        create_user(db_session, username="eve", email="eve2@e.com")
        db_session.commit()

These tests are still fast and now they catch real SQL errors. However, SQLite has different behavior from PostgreSQL in some cases — for example, it is more permissive with types and does not enforce certain constraints the same way. This brings us to integration testing.

Integration Tests: Using a Real Database

Integration tests run against the same database engine you use in production. This catches engine-specific bugs like JSON column handling, array types, or strict foreign key enforcement. The standard pattern is to use a separate test database that is created and torn down for each test or test session.

Using pytest Fixtures for Database Lifecycle

# tests/conftest_integration.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models import Base

TEST_DATABASE_URL = "postgresql://localhost/blogdb_test"


@pytest.fixture(scope="session")
def engine():
    engine = create_engine(TEST_DATABASE_URL)
    Base.metadata.create_all(engine)
    yield engine
    Base.metadata.drop_all(engine)
    engine.dispose()


@pytest.fixture
def db_session(engine):
    connection = engine.connect()
    transaction = connection.begin()
    Session = sessionmaker(bind=connection)
    session = Session()

    try:
        yield session
    finally:
        session.close()
        transaction.rollback()
        connection.close()

The key trick here is the transaction rollback pattern. Each test gets its own connection and transaction. At the end of the test, the transaction is rolled back, so the database returns to a clean state without needing to drop and recreate tables. This is dramatically faster than truncating tables between tests.

# tests/test_integration.py
import pytest
from app.services import create_user, create_post, get_posts_by_user


def test_full_workflow(engine, db_session):
    user = create_user(db_session, username="integration", email="i@e.com")
    db_session.flush()
    post = create_post(db_session, user.id, "Title", "Body")
    db_session.flush()

    fetched = get_posts_by_user(db_session, "integration")
    assert len(fetched) == 1
    assert fetched[0].title == "Title"


def test_cascade_delete(engine, db_session):
    user = create_user(db_session, username="cascade", email="c@e.com")
    db_session.flush()
    create_post(db_session, user.id, "P1", "B1")
    create_post(db_session, user.id, "P2", "B2")
    db_session.flush()

    db_session.delete(user)
    db_session.flush()

    posts = get_posts_by_user(db_session, "cascade")
    assert posts == []

Handling Concurrent Test Isolation

If you run tests in parallel with pytest-xdist, the shared transaction approach can break because multiple workers may hit the same database. A common solution is to give each worker its own schema or database. With PostgreSQL, you can create a schema per worker:

# tests/conftest_parallel.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models import Base

BASE_URL = "postgresql://localhost/blogdb_test"


@pytest.fixture(scope="session")
def engine(worker_id):
    engine = create_engine(BASE_URL)
    schema = f"test_{worker_id}"
    with engine.connect() as conn:
        conn.execute(f'CREATE SCHEMA IF NOT EXISTS {schema}')
        conn.commit()

    # Set search_path so all tables land in this schema
    test_engine = create_engine(
        BASE_URL,
        connect_args={"options": f"-csearch_path={schema}"},
    )
    Base.metadata.create_all(test_engine)
    yield test_engine
    Base.metadata.drop_all(test_engine)
    with engine.connect() as conn:
        conn.execute(f'DROP SCHEMA {schema} CASCADE')
        conn.commit()
    engine.dispose()

Testing Async SQLAlchemy

If you are using SQLAlchemy 2.0's async support, your fixtures need to be async as well. Here is how to adapt the pattern using pytest-asyncio:

# app/database_async.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession

DATABASE_URL = "postgresql+asyncpg://localhost/blogdb"

async_engine = create_async_engine(DATABASE_URL, echo=False)
AsyncSessionLocal = async_sessionmaker(async_engine, expire_on_commit=False)
# tests/conftest_async.py
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app.models import Base

TEST_URL = "postgresql+asyncpg://localhost/blogdb_test"


@pytest_asyncio.fixture
async def async_session():
    engine = create_async_engine(TEST_URL)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

    Session = async_sessionmaker(engine, expire_on_commit=False)
    async with Session() as session:
        yield session

    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
    await engine.dispose()
# tests/test_async.py
import pytest
from sqlalchemy import select
from app.models import User


@pytest.mark.asyncio
async def test_async_create_and_query(async_session):
    user = User(username="async_user", email="a@e.com")
    async_session.add(user)
    await async_session.commit()

    stmt = select(User).where(User.username == "async_user")
    result = await async_session.execute(stmt)
    fetched = result.scalar_one()
    assert fetched.email == "a@e.com"

Testing FastAPI or Flask Integration

For end-to-end tests, you want to override the dependency injection so that your HTTP routes use the test database session instead of the production one. Here is a FastAPI example:

# app/main.py
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
from app.database import get_session, SessionLocal
from app.services import create_user, get_posts_by_user
from app.models import User

app = FastAPI()


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


@app.post("/users")
def post_user(username: str, email: str, db: Session = Depends(get_db)):
    user = create_user(db, username=username, email=email)
    db.commit()
    return {"id": user.id, "username": user.username}


@app.get("/users/{username}/posts")
def list_posts(username: str, db: Session = Depends(get_db)):
    posts = get_posts_by_user(db, username)
    return [{"id": p.id, "title": p.title} for p in posts]
# tests/test_api.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.main import app, get_db
from app.models import Base


@pytest.fixture
def client():
    engine = create_engine(
        "sqlite://",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    Base.metadata.create_all(engine)
    TestingSession = sessionmaker(bind=engine)

    def override_get_db():
        db = TestingSession()
        try:
            yield db
        finally:
            db.close()

    app.dependency_overrides[get_db] = override_get_db
    yield TestClient(app)
    app.dependency_overrides.clear()
    Base.metadata.drop_all(engine)


def test_create_user_endpoint(client):
    response = client.post("/users", params={"username": "zoe", "email": "z@e.com"})
    assert response.status_code == 200
    assert response.json()["username"] == "zoe"


def test_list_posts_endpoint(client):
    client.post("/users", params={"username": "zoe", "email": "z@e.com"})
    response = client.get("/users/zoe/posts")
    assert response.status_code == 200
    assert response.json() == []

Best Practices

A Simple Data Factory Example

# tests/factories.py
from app.models import User, Post


def make_user(session, username="default_user", email=None):
    email = email or f"{username}@example.com"
    user = User(username=username, email=email)
    session.add(user)
    session.flush()
    return user


def make_post(session, author, title="Default Title", body="Default body"):
    post = Post(title=title, body=body, author=author)
    session.add(post)
    session.flush()
    return post
# tests/test_factories.py
from tests.factories import make_user, make_post
from app.services import get_posts_by_user


def test_factory_creates_related_data(db_session):
    author = make_user(db_session, username="factory")
    make_post(db_session, author, title="One")
    make_post(db_session, author, title="Two")
    db_session.commit()

    posts = get_posts_by_user(db_session, "factory")
    titles = {p.title for p in posts}
    assert titles == {"One", "Two"}

Conclusion

Testing SQLAlchemy applications effectively requires a layered approach. Unit tests with mocked or in-memory sessions give you speed and confidence in your business logic, while integration tests against a real database catch the subtle engine-specific issues that mocks miss. By combining the transaction rollback pattern for isolation, dependency overrides for end-to-end tests, and factories for clean data setup, you can build a test suite that is fast, reliable, and maintainable. The investment pays off every time you refactor a query or add a new model — your tests become a safety net that lets you move quickly without fear of breaking production.

— Ad —

Google AdSense will appear here after approval

← Back to all articles