Mocking vs using a real database in SQLAlchemy tests

Mock the layer above the session, never the session itself: a MagicMock standing in for AsyncSession asserts that your code called the ORM in a particular way, which is neither what you care about nor stable, while a real database in a rolled-back transaction is fast enough that the trade was never necessary. This is the testing-strategy question under testing async SQLAlchemy applications.

Quick Answer

A mock asserts calls; a database asserts behaviour Two columns. A mocked AsyncSession can verify that a function called add, or execute, or commit, and with what arguments — which is a statement about your code's internal choices. It cannot verify that the SQL those calls generate is valid, that a unique constraint rejects a duplicate, that a Decimal round-trips at the right scale, that a relationship loads, or that the transaction committed anything. Every one of those is a real defect a mocked test reports as passing, and every one of them is caught by the same test run against a real database in a rolled-back transaction. a mocked session verifies which methods were called with which arguments in which order that is all it can see a mocked session cannot verify that the SQL is even valid that constraints reject bad data that types round-trip correctly that anything was committed The list on the right is the list of defects integration tests exist to find, which is why mocking the session removes exactly the value the test was written for.

Before — a mocked session:

from unittest.mock import AsyncMock, MagicMock


async def test_place_order_mocked():
    session = MagicMock()
    session.execute = AsyncMock(return_value=MagicMock())
    session.commit = AsyncMock()

    await place_order(session, customer_id=1, total=Decimal("42.00"))

    session.add.assert_called_once()      # asserts a method call, not a result
    session.commit.assert_awaited_once()  # passes even if the INSERT is invalid SQL

After — a real session in a rolled-back transaction:

import pytest
from sqlalchemy import select


@pytest.mark.asyncio
async def test_place_order_persists_the_row(session):
    customer = Customer(email="ada@example.com")
    session.add(customer)
    await session.flush()

    order = await place_order(session, customer.id, Decimal("42.00"))

    stored = (await session.execute(
        select(Order).where(Order.id == order.id)
    )).scalar_one()
    assert stored.total == Decimal("42.00")
    assert stored.customer_id == customer.id

The second test is a handful of milliseconds slower and catches an entire category the first cannot: invalid SQL, a violated constraint, a Decimal that lost its scale, a foreign key pointing nowhere, a transaction that never committed.

Execution Context & Async Workflow Integration

Mocking an AsyncSession is harder than mocking a synchronous one, and the extra difficulty is a useful signal rather than an obstacle to route around. session.execute() is awaitable, so it needs an AsyncMock; its result supports scalars(), which supports all() and first() and one(); and scalar_one() raises particular exceptions in particular cases that the mock will not.

# The mock a moderately realistic query needs — and it still proves nothing
from unittest.mock import AsyncMock, MagicMock


def fake_session_returning(rows: list[Order]) -> MagicMock:
    scalars = MagicMock()
    scalars.all.return_value = rows
    scalars.first.return_value = rows[0] if rows else None
    scalars.unique.return_value = scalars

    result = MagicMock()
    result.scalars.return_value = scalars

    session = MagicMock()
    session.execute = AsyncMock(return_value=result)
    session.commit = AsyncMock()
    session.flush = AsyncMock()
    return session

That builder is twenty lines that encode an assumption about SQLAlchemy's result API. When the code under test switches from scalars().all() to scalars().unique().all() — a correctness fix required by a joined eager load — the mock returns a MagicMock rather than a list and the test fails for a reason unrelated to the change. When the code switches to session.scalars(), the mock has no such method and the test fails again.

The deeper problem is that under asyncio the failures worth catching are precisely the ones a mock removes. MissingGreenlet from a relationship access, an expired attribute after commit, a session shared between two tasks — every one of those is a real-session behaviour with no mock equivalent. A suite built on mocked sessions passes cleanly and leaves all of them to production. The four sites where that error reliably appears are catalogued in fixing GreenletSpawnError in async SQLAlchemy workflows, and a mocked session hides all four.

Mock the boundary, not the database Three layers. Pure logic — pricing rules, validation, state machines — should not touch a session at all, and testing it needs no database and no mock, only plain function calls. Persistence logic is about the database by definition, so it wants a real one; the rollback fixture makes that fast enough that there is no argument for substituting anything. External services — a payment provider, an email gateway — are the genuine mocking boundary: they are slow, non-deterministic and have side effects you cannot undo, which is exactly the profile a test double exists for. pure logic no session, no database plain function calls microseconds persistence logic a real database rolled back per test single-digit milliseconds external services a mock or a fake slow, non-deterministic, irreversible the real mocking boundary If persistence logic is hard to separate from pure logic, that is the finding — the fix is in the design, not in a more elaborate mock.

Resolving Warnings, Errors & Common Mistakes

SymptomRoot CauseProduction Fix
TypeError: object MagicMock can't be used in 'await' expressionsession.execute mocked with MagicMock rather than AsyncMock.Use AsyncMock for every awaitable — or stop mocking the session.
A test breaks when the query adds .unique()The mock encodes the exact result-API chain the code happened to use.The test is asserting implementation. Move to a real session and assert on rows.
AttributeError: Mock object has no attribute 'scalar_one'The code moved to a different result accessor.Same cause, same fix.
Tests pass; production raises IntegrityErrorNo constraint exists in a mock, so no test could have caught it.Exercise the constraint against a real database, ideally with a test that asserts the violation is raised.
Tests pass; production raises MissingGreenletNo lazy load exists in a mock.Real session, real relationship, real loader option.
A mocked repository test passes while the real query returns nothingThe repository mock and the repository disagree, and nothing checks that.Contract-test the real repository against a real database; mock it only above that line.

Advanced: The Repository Seam That Makes Mocking Honest

There is a legitimate place for a test double, and it is not the session. It is a narrow interface between the code that decides what should happen and the code that knows how to persist it.

from __future__ import annotations

from typing import Protocol


class OrderRepository(Protocol):
    """The seam: named operations, not ORM calls."""

    async def get(self, order_id: int) -> Order | None: ...
    async def add(self, order: Order) -> None: ...
    async def outstanding_for(self, customer_id: int) -> list[Order]: ...


class SqlOrderRepository:
    def __init__(self, session: AsyncSession) -> None:
        self._session = session

    async def get(self, order_id: int) -> Order | None:
        return await self._session.get(Order, order_id)

    async def add(self, order: Order) -> None:
        self._session.add(order)

    async def outstanding_for(self, customer_id: int) -> list[Order]:
        stmt = (
            select(Order)
            .where(Order.customer_id == customer_id, Order.status == "outstanding")
            .options(selectinload(Order.items))
        )
        return list((await self._session.execute(stmt)).scalars())


class FakeOrderRepository:
    """An in-memory implementation, not a mock — it behaves rather than records."""

    def __init__(self) -> None:
        self._rows: dict[int, Order] = {}
        self._next = 1

    async def get(self, order_id: int) -> Order | None:
        return self._rows.get(order_id)

    async def add(self, order: Order) -> None:
        order.id = order.id or self._next
        self._next += 1
        self._rows[order.id] = order

    async def outstanding_for(self, customer_id: int) -> list[Order]:
        return [o for o in self._rows.values()
                if o.customer_id == customer_id and o.status == "outstanding"]

Two properties make this honest where a mocked session is not. The fake behaves — it stores and returns objects — so a test using it asserts on outcomes rather than on calls, and refactoring the service does not break it. And the interface is small enough that the fake can be kept faithful, which is the condition every test-double argument depends on and which a mocked ORM never satisfies.

The remaining obligation is a contract test: SqlOrderRepository must be exercised against a real database, and ideally both implementations should be run through the same test body.

@pytest.fixture(params=["sql", "fake"])
def repository(request, session):
    return SqlOrderRepository(session) if request.param == "sql" else FakeOrderRepository()


@pytest.mark.asyncio
async def test_outstanding_excludes_settled(repository):
    await repository.add(Order(customer_id=1, status="outstanding", total=Decimal("5")))
    await repository.add(Order(customer_id=1, status="settled", total=Decimal("7")))
    assert len(await repository.outstanding_for(1)) == 1

Run against both implementations, this asserts that the fake and the real repository agree — which is the only thing that makes tests written against the fake meaningful.

Three assertions that look like tests and are not Three anti-patterns. Asserting on a generated SQL string couples the test to SQLAlchemy's compiler: a version upgrade that changes parameter naming or whitespace breaks a test that was never about that, and the test still cannot tell you whether the query returns the right rows. Asserting on ORM call order — that add was called before flush — encodes an implementation detail that any legitimate refactor will change. And asserting that commit was called says nothing about whether the data is correct, or even whether the transaction succeeded; it is a test of the code you just wrote, written by reading it. asserting on the generated SQL string couples the test to the compiler, not to behaviour a version upgrade breaks it, and it never proved the rows were right asserting on ORM call order add before flush, flush before commit encodes an implementation detail every refactor will change asserting that commit() was called says nothing about whether the data is correct assert the row exists instead — that is the thing you actually mean All three share a shape: they assert what the code does rather than what it achieves, so they pass when the behaviour is broken and fail when the implementation improves.

Choosing a Strategy per Test, Not per Project

The useful question is not "mock or not" but "what is this test about", and the answer usually points at exactly one strategy.

A test about a rule — how a discount is calculated, when an order may be cancelled, which state transitions are legal — should not involve persistence at all. If it currently does, the rule is entangled with storage and extracting it is the real improvement; the test getting faster is a side effect.

A test about persistence — that a query returns the right rows, that a constraint holds, that a migration produced the expected schema — needs the database. There is no substitute that proves anything, and with rollback isolation it costs single-digit milliseconds.

A test about an interaction with something outside the process — a payment provider, an email gateway, a webhook — needs a double, because the real thing is slow, non-deterministic and has effects you cannot roll back. Prefer a fake with behaviour over a mock that records calls, and cover the real integration separately in a small number of tests that run against a sandbox.

A test about wiring — that the endpoint reaches the service, which reaches the database, and returns the right status code — needs the whole stack, and should be a small number of tests rather than a large one. These are where an override of the FastAPI session dependency earns its place, as described in the parent guide.

Applied consistently, this produces a suite where mocks appear only at genuine process boundaries, the database appears wherever the database is the subject, and most tests involve neither. The proportion that ends up mocked is small — and every one of them is mocking something that genuinely cannot be run in a test.

Frequently Asked Questions

Is it ever right to mock AsyncSession directly?

Rarely, and mostly in error-injection tests: forcing commit() to raise an OperationalError to exercise a retry path can be simpler than provoking a real serialisation failure. Even then, patching one method on a real session is preferable to substituting the whole object, because everything else still behaves.

Are database tests too slow to run on every save?

Not with rollback isolation. A test against an in-memory SQLite database is a fraction of a millisecond, and against a local PostgreSQL a few milliseconds — dominated by the test body rather than by setup. The cost people remember comes from truncation-based fixtures, which the rollback pattern removes.

What about asserting on the SQL a query generates?

It is occasionally useful as a regression guard — confirming that a query still uses an index-friendly predicate, for instance — but as a primary assertion it couples the test to the compiler and proves nothing about the rows. If the concern is the plan rather than the string, assert on EXPLAIN output against a real database instead.

How do I test error paths without a real failure?

Provoke the real failure where you can: a duplicate insert for IntegrityError, a deliberately conflicting transaction for a serialisation failure. Where you cannot — a network partition, a disk-full condition — patch the single call that should fail on an otherwise real session, so everything except the injected failure is genuine.