Jack's blog

Auto rollback database transactions with Flask-SQLAlchemy and pytest

TLDR: I wanted (the not uncommon use-case) to having each test run in its own isolated transaction.

This makes testing application logic easier as arbitrary database changes can be made within each test with confidence that data will be rolled back once the test exits.

At first I tried out the pytest-flask-sqlalchemy plugin, but this appears to be incompatible with Flask-SQLAlchemhy 3.0

After quite a bit of searching I came across this Github Issue on the Flask-SQLAlchemy issue tracker which seems to be the only solution that currently works

Below is an example of how that snippet fits into an example toy Flaksk app:

from flask import Blueprint, Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


db = SQLAlchemy(model_class=Base)


class User(Base):
    __tablename__ = "user"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(unique=True)


index = Blueprint("index", __name__, url_prefix="/")


@index.route("/")
def users():
    return [
        {"id": user.id, "email": user.email}
        for user in db.session.scalars(select(User))
    ]


def create_app(db_uri="sqlite:///project.db"):
    app = Flask(__name__)
    app.config["SQLALCHEMY_DATABASE_URI"] = db_uri
    db.init_app(app)
    app.register_blueprint(index)
    return app

Here's an example conftest.py

import pytest

from app import User, create_app, db


@pytest.fixture(scope="session")
def app():
    app = create_app("sqlite:///:memory:")
    yield app


@pytest.fixture(autouse=True)
def app_ctx(app):
    with app.app_context():
        yield app


@pytest.fixture(scope="session")
def client(app):
    return app.test_client()


@pytest.fixture(scope="session", autouse=True)
def tables(app):
    with app.app_context():
        db.create_all()


@pytest.fixture(scope="function")
def database(app_ctx):
    # https://github.com/pallets-eco/flask-sqlalchemy/issues/1171
    engines = db.engines

    engine_cleanup = []

    for key, engine in engines.items():
        connection = engine.connect()
        transaction = connection.begin_nested()
        engines[key] = connection  # type: ignore
        engine_cleanup.append((key, engine, connection, transaction))

    yield db

    for key, engine, connection, transaction in engine_cleanup:
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            transaction.rollback()
        connection.close()
        engines[key] = engine  # type: ignore

This enables me to then write tests that depend on the db.session global which are completely self contained:

def test_a(client, database):
    user = User(email="test@google.com")
    database.session.add(user)
    database.session.commit()
    resp = client.get("/")
    assert resp.status_code == 200
    assert resp.json == [{"email": "test@google.com", "id": 1}]


def test_b(client, database):
    user = User(email="test@google.com")
    database.session.add(user)
    database.session.commit()
    resp = client.get("/")
    assert resp.status_code == 200
    assert resp.json == [{"email": "test@google.com", "id": 1}]