Database tests often fail in two opposite ways. A fully mocked repository test is fast but cannot reveal mapping, constraint, or transaction defects. A shared integration database is realistic but becomes order-dependent as records accumulate. Transaction rollback offers a practical middle layer: execute real SQL against the test database, then return the database to its prior state after each case. The pattern is simple in concept, but session behavior and application commits need careful handling.
Define the isolation boundary first
A SQLAlchemy Engine manages connectivity and pooling. A Connection represents a checked-out database connection. A Session adds the ORM unit-of-work, identity map, and transaction coordination. Reusing an engine across tests can be efficient; reusing a mutable Session usually is not. Function-scoped sessions keep pending objects and cached identities from leaking between cases.
The basic boundary is an outer transaction opened by the test fixture. The application performs reads, writes, flushes, and possibly session-level commits through a Session bound to that connection. At teardown, the fixture closes the Session and rolls back the outer transaction. The database sees real constraints and queries during the test, while committed test data does not survive the boundary when the joining mode is configured correctly for the database and test design.
Account for code that calls commit
If application code never commits, a simple connection transaction and final rollback may be sufficient. Real service code often calls Session.commit(), and the test should exercise that path. SQLAlchemy's join_transaction_mode controls how a Session bound to an already-transactional Connection participates. The create_savepoint mode tells the Session to use its own savepoint over the external transaction, allowing a session-level commit without committing the fixture's outer transaction.
Savepoint support and driver behavior vary, so this is not a copy-paste guarantee for every database. SQLite in particular has driver-specific caveats documented by SQLAlchemy. Run the isolation pattern against the same database family used in the relevant environment when dialect behavior is part of the risk. If a lightweight SQLite test is retained, label its scope honestly and complement it with database-specific integration coverage.
import pytest
from sqlalchemy.orm import Session
@pytest.fixture
def db_session(engine):
connection = engine.connect()
outer_transaction = connection.begin()
session = Session(
bind=connection,
join_transaction_mode="create_savepoint",
)
try:
yield session
finally:
session.close()
outer_transaction.rollback()
connection.close()Test observable database behavior
A database test should protect something the database layer can uniquely prove: a uniqueness constraint, cascade rule, nullable boundary, relationship mapping, query filter, locking assumption, or transaction outcome. Repeating pure business calculations through an ORM adds cost without useful coverage. Conversely, mocking a Session while claiming to test persistence can miss incorrect joins and constraints.
After a write, flush when the test needs the database to evaluate constraints before commit. Understand that flush sends SQL but does not independently make the transaction durable. Expire or refresh an object when the assertion must observe database-produced values rather than the current Python object's cached attributes. For a query test, create only the minimum rows needed to distinguish included from excluded results.
- Assert the intended row or relationship, not an entire database dump.
- Use factories with explicit overrides so important scenario values remain visible.
- Avoid fixed primary keys unless the key itself is part of the contract.
- Confirm teardown by running the case repeatedly and in a different order.
Know what rollback cannot isolate
A transaction cannot undo every external effect. Messages published to a broker, files written to object storage, emails sent, and calls to another service need separate fakes, test doubles, or cleanup policies. Some database operations and sequences may also behave outside the assumptions of a transaction, depending on the database. Treat rollback as one boundary, not a universal reset button.
The pattern is also a poor fit for tests that intentionally use multiple independent connections to verify visibility, locking, or concurrent transactions. Those tests need a controlled database lifecycle and explicit cleanup because a single outer connection would hide the behavior under examination. Keep them in a smaller integration layer with clear environment requirements.
Prevent false confidence from in-memory databases
An in-memory database is fast, but dialect differences matter. Data types, collation, JSON operators, isolation levels, generated values, and constraint behavior may differ from production. Use it for ORM mechanics that are portable and deterministic, not as proof that a PostgreSQL- or MySQL-specific query works.
A layered strategy is usually stronger: many rollback-isolated repository tests for fast feedback, a focused set against the target database for dialect and migration behavior, and a few end-to-end flows through the service boundary. Each layer should state what it proves. That makes database automation credible without pretending that one fixture can represent every production condition.
Practical takeaways
What to carry into the next test suite
- Reuse connectivity when safe, but create a fresh Session and transaction boundary per test.
- Configure and verify how application-level commits join the fixture's outer transaction.
- Use database tests for mappings, constraints, queries, and transaction behavior that mocks cannot prove.
- Complement rollback with separate control of queues, files, services, and multi-connection scenarios.
References
Primary documentation and technical references used in this article.