Overriding the database session dependency in FastAPI tests

Override the session dependency with a factory bound to a connection whose transaction the test rolls back — async_sessionmaker(bind=connection, join_transaction_mode="create_savepoint") — so the endpoint's own commit() releases a savepoint instead of persisting, and teardown is one rollback. This guide belongs to integrating SQLAlchemy async with FastAPI and Starlette.

Quick Answer

Without an override, the endpoint opens its own session on its own connection, so the test and the endpoint cannot see each other's data.

One connection, one transaction, rolled back Five steps. The test opens a connection and begins a transaction on it. A session factory is bound to that connection, so every session the application creates joins the same transaction. The dependency override hands those sessions to the endpoint. The test drives the endpoint through an HTTP client and asserts. At teardown the outer transaction is rolled back, so nothing the test wrote survives and no truncation is needed. connection + begin() one transaction owned by the test a factory bound to that connection join_transaction_mode app sessions join it dependency_overrides[get_session] the endpoint gets that session unchanged app code the test calls the endpoint and asserts writes are visible rollback at teardown nothing persists The endpoint commits; the outer transaction does not. That is what makes the isolation free.

Before — the test and the endpoint use different transactions:

import httpx
import pytest

from shop.main import app


@pytest.mark.asyncio
async def test_create_order(session, customer_factory):
    customer_id = await customer_factory()
    await session.commit()          # the test's transaction

    transport = httpx.ASGITransport(app=app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.post("/orders", json={"customer_id": customer_id})

    assert response.status_code == 201
# The endpoint used the application engine — possibly a different database entirely —
# and anything it wrote is invisible to the test session.

After — one connection, one transaction, rolled back at teardown:

from collections.abc import AsyncIterator

import httpx
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

from shop.db import get_session
from shop.main import app


@pytest_asyncio.fixture
async def db_session(test_engine) -> AsyncIterator[AsyncSession]:
    async with test_engine.connect() as connection:
        await connection.begin()                     # the outer transaction
        factory = async_sessionmaker(
            bind=connection,
            expire_on_commit=False,
            join_transaction_mode="create_savepoint",
        )
        async with factory() as session:
            yield session
        await connection.rollback()                  # nothing persists


@pytest_asyncio.fixture
async def client(db_session) -> AsyncIterator[httpx.AsyncClient]:
    async def override() -> AsyncIterator[AsyncSession]:
        yield db_session

    app.dependency_overrides[get_session] = override
    transport = httpx.ASGITransport(app=app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as http:
        yield http
    app.dependency_overrides.clear()

join_transaction_mode="create_savepoint" is the key setting: when the endpoint calls commit(), SQLAlchemy releases a savepoint inside the test's transaction rather than committing it, so the write is visible to the test and still disappears at rollback.

Execution Context & Async Workflow Integration

FastAPI resolves dependencies by callable identity, and app.dependency_overrides is a dictionary keyed on the original callable. Replacing get_session therefore changes what every endpoint receives without touching application code — which is the whole point: the code under test is the production code.

Why the override is needed Left: the endpoint opens its own session from the application factory, so it uses a different connection and a different transaction than the test — the test cannot see what the endpoint wrote, and the endpoint cannot see the test fixtures. Right: the override hands the endpoint a session bound to the test transaction, so both sides see the same data. no override the endpoint uses its own session a different connection fixtures invisible to the endpoint writes invisible to the test dependency_overrides the endpoint gets the test session one connection, one transaction fixtures and writes both visible rolled back at teardown This is the same boundary that breaks background tasks: sessions do not share transactions by default.

The transaction arrangement is what makes the tests both isolated and fast. The fixture holds one connection with an outer transaction. A session factory bound to that connection — not to the engine — means every session created from it joins the existing transaction rather than starting its own. join_transaction_mode="create_savepoint" tells SQLAlchemy what to do when such a session commits: begin a savepoint on entry and release it on commit, so the application's commit() succeeds, its writes become visible, and the outer transaction still owns everything. One rollback() at teardown undoes the lot in microseconds.

That is the same fixture described in rolling back database state between async tests; the addition here is the override that makes an endpoint use it.

httpx.ASGITransport calls the application in-process, with no socket and no server. That keeps tests fast and means the application's lifespan does not run unless asked — which is usually what you want in a test, because the lifespan would create the real engine. When a test does need the lifespan (to check start-up behaviour), httpx.ASGITransport combined with LifespanManager from asgi-lifespan runs it explicitly.

Two async-specific details matter.

The event loop must be shared between the fixture and the request. pytest-asyncio's default is a loop per test function, which is fine as long as the engine fixture is scoped the same way or the loop scope is widened; mixing a session-scoped engine with function-scoped loops produces got Future attached to a different loop, the failure described in disposing async engines on shutdown and in forked workers.

Background tasks run before the client call returns with ASGITransport, so their effects are assertable immediately. But a task that opens its own session from the application factory — which is what it should do — will not see the test's uncommitted transaction. Either override that factory too, or accept that such tests need committed fixture data and truncation-based cleanup, as running background tasks with a fresh AsyncSession in FastAPI notes.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
The endpoint cannot see fixture rowsNo override, so it used its own session and connection.app.dependency_overrides[get_session].
Test data persists between testsThe endpoint's commit() committed the outer transaction.Bind the factory to the connection with join_transaction_mode="create_savepoint".
InvalidRequestError: A transaction is already begun on this SessionA session bound to a connection that already has a transaction, without a join mode.Set join_transaction_mode.
got Future attached to a different loopA session-scoped engine used across function-scoped event loops.Match the scopes, or create the engine per test.
Overrides leak into the next testdependency_overrides not cleared.Clear it in the fixture teardown.
The test writes to the development databaseThe application engine was used, not a test engine.Create a test engine and override the dependency to use it.
A background task sees none of the test dataIt opened its own session, outside the test transaction.Override the factory it uses, or use committed fixtures for those tests.
Three isolation strategies Three tiles. A rolled-back outer transaction is fastest and keeps the schema, at the cost of some care with commits inside the application. Truncating the tables after each test is simple and slower, and resets sequences if asked. Recreating the schema per test is slowest and fully isolated, which suits a small model or a suite that alters the schema. rollback an outer transaction microseconds needs join_transaction_mode TRUNCATE after each test milliseconds simple, resets sequences recreate the schema hundreds of ms fully isolated The first is the default worth aiming at; the second is a fine fallback when commits get complicated.

The persistence case is the one worth verifying deliberately, because a suite that accidentally commits passes for a long time before the leakage causes a confusing failure. One test proves the arrangement works:

import pytest
from sqlalchemy import func, select

from shop.models import Order


@pytest.mark.asyncio
async def test_isolation_holds(client, db_session, customer_factory):
    customer_id = await customer_factory()
    response = await client.post("/orders", json={"customer_id": customer_id})
    assert response.status_code == 201
    # Visible inside the test's transaction:
    assert await db_session.scalar(select(func.count(Order.id))) == 1


@pytest.mark.asyncio
async def test_previous_test_left_nothing(db_session):
    assert await db_session.scalar(select(func.count(Order.id))) == 0

Run in order, those two assert both halves: the endpoint's write is visible during the test and gone afterwards. If the second fails, the savepoint arrangement is not in place and the suite is accumulating state.

Overriding more than the session is sometimes necessary and worth keeping deliberate. Authentication is the common one — a current_user dependency replaced with a fixed user — and it is better to override the dependency than to construct tokens, because the test then exercises the endpoint rather than the auth provider:

from shop.auth import current_user
from shop.models import User


@pytest_asyncio.fixture
async def as_admin(client, db_session):
    admin = User(email="admin@example.com", is_staff=True)
    db_session.add(admin)
    await db_session.flush()
    app.dependency_overrides[current_user] = lambda: admin
    yield client
    app.dependency_overrides.pop(current_user, None)

Advanced: What to Assert, and What Not to Mock

An endpoint test is expensive compared with a unit test, so it should assert the things only it can. Four are worth the cost.

What an endpoint test is for Four things. That the wiring works: the dependency, the session, the transaction boundary and the response are all connected. That the status codes and payload shapes are what the contract says. That committed state is what the endpoint claims — checked by reading the database, not the response. And that post-response work, such as a background task, actually ran and used its own session. the wiring dependency → session → transaction → response, end to end the contract status codes, payload shape, validation errors the committed state read it back from the database rather than trusting the response body post-response work background tasks ran, and opened their own sessions

The committed state, read from the database. A response body reflects what the handler intended; the database reflects what happened. Asserting both catches serialisation bugs and transaction bugs separately:

import pytest
from sqlalchemy import select

from shop.models import Order


@pytest.mark.asyncio
async def test_create_order_persists_the_total(client, db_session, customer_factory):
    customer_id = await customer_factory()

    response = await client.post(
        "/orders",
        json={"customer_id": customer_id, "lines": [{"sku": "A1", "quantity": 2}]},
    )

    assert response.status_code == 201
    body = response.json()

    order = await db_session.scalar(select(Order).where(Order.id == body["id"]))
    assert order is not None
    assert order.total_cents == body["total_cents"]     # response matches the row
    assert order.status == "pending"

The contract on failure. Validation errors, missing resources and conflicts have status codes and shapes that clients depend on. A test per failure mode is cheap and prevents a refactor from turning a 409 into a 500.

Transaction boundaries. An endpoint that should write nothing on failure is worth asserting: trigger the failure, then count the rows. This catches a handler that commits before validating.

The database, not a mock of it. Mocking the session in an endpoint test removes the only thing the test was for. The interactions that break in production — constraint violations, transaction boundaries, lazy loads that raise under async, the actual SQL — are exactly what a mock cannot reproduce. Mock the things outside the system: the payment provider, the mail sender, the object store.

@pytest.fixture
def payments(monkeypatch):
    captured = []

    async def capture(order_id: int, amount_cents: int) -> str:
        captured.append((order_id, amount_cents))
        return "ref_123"

    monkeypatch.setattr("shop.payments.capture", capture)
    return captured

That distinction — real database, fake outside world — is what makes endpoint tests worth their runtime. A suite that mocks the database runs faster and tells you only that the Python is syntactically connected.

For the handful of tests that genuinely need committed data — background tasks, anything reading through a second connection — keep a separate fixture that commits and cleans up by truncation, and mark those tests so the difference is visible. Two isolation strategies in one suite is fine as long as each test says which it uses.

Structuring the Fixture Stack

The fixtures in this guide compose into a small stack, and getting the order and the scopes right once makes every endpoint test afterwards a few lines.

Whose engine does the test use? Left: the test overrides the dependency but the application engine is still the one connected to the development database, so a mistake writes to real data and the test depends on the developer environment. Right: the test creates its own engine against a throwaway database, overrides the dependency to use it, and nothing else in the process can reach anything else. the application engine points at the development database test data mixes with real data a forgotten override writes for real results depend on local state a test engine a container or a temporary database created and dropped per session nothing shared with development reproducible in CI Combine both: a test engine, and an override so the application never reaches its own.
# tests/conftest.py
from collections.abc import AsyncIterator

import httpx
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from shop.db import get_session
from shop.main import app
from shop.models import Base


@pytest_asyncio.fixture(scope="session")
async def test_engine(postgres_url: str):
    """One engine and one schema for the whole run."""
    engine = create_async_engine(postgres_url)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()


@pytest_asyncio.fixture
async def db_session(test_engine) -> AsyncIterator[AsyncSession]:
    """A session inside a transaction that is rolled back after the test."""
    async with test_engine.connect() as connection:
        await connection.begin()
        factory = async_sessionmaker(
            bind=connection,
            expire_on_commit=False,
            join_transaction_mode="create_savepoint",
        )
        async with factory() as session:
            yield session
        await connection.rollback()


@pytest_asyncio.fixture
async def client(db_session) -> AsyncIterator[httpx.AsyncClient]:
    async def override() -> AsyncIterator[AsyncSession]:
        yield db_session

    app.dependency_overrides[get_session] = override
    async with httpx.AsyncClient(
        transport=httpx.ASGITransport(app=app), base_url="http://test"
    ) as http:
        yield http
    app.dependency_overrides.clear()

Four decisions are encoded there. The engine and schema are session-scoped, so create_all runs once rather than per test. The transaction is function-scoped, which is where the isolation comes from. The override is set and cleared alongside the client, so no test can leak it. And the event loop scope must match the engine scope — set asyncio_default_fixture_loop_scope = "session" in the pytest configuration, or the session-scoped engine will be used from the wrong loop.

Building the schema with create_all is a deliberate simplification worth noting: it creates what the models describe, not what the migrations produce. For a suite that also needs to verify the migration path, a second session-scoped fixture that runs alembic upgrade head instead is the alternative — the arrangement in running Alembic migrations programmatically from async code.

The postgres_url fixture is whatever provides a throwaway database: a testcontainer, a CI service, or a locally running instance with a dedicated database name. Using a real PostgreSQL rather than SQLite is what makes these tests meaningful, for the reasons in enabling SQLite foreign keys and WAL mode with aiosqlite.

Frequently Asked Questions

How do I make a FastAPI endpoint use my test session?

Set app.dependency_overrides[get_session] to a callable that yields the test session, and clear it in the fixture teardown. FastAPI keys overrides on the original callable, so no application code changes.

Why does test data persist between tests?

Because the endpoint's commit() committed the transaction. Bind the session factory to a connection with join_transaction_mode="create_savepoint" so a commit releases a savepoint and the outer transaction can be rolled back.

Should I mock the database in endpoint tests?

No. Constraint violations, transaction boundaries and async lazy-load errors are exactly what the test exists to catch, and a mock reproduces none of them. Mock the outside world instead.

Do background tasks see the test transaction?

Not if they open their own session from the application factory, which is what they should do. Override that factory for those tests, or use committed fixture data with truncation-based cleanup.