Skip to content

Sharing a database engine

A suite that hits a real database needs the connection pool built once, not once per test, while still keeping every test's writes invisible to its neighbours. Two fixtures do this: a session-scoped engine, and a function-scoped session wrapped in a transaction that always rolls back.

@voci.fixture(scope="session")
async def engine(
    tmp: voci.TmpPathFactory = Depends(voci.tmp_path_factory),
) -> AsyncIterator[AsyncEngine]:
    """One engine for the entire run, shared by every concurrent test.

    Session-scoped fixtures are built once, on first use, under a single-flight guard: 200 tests
    starting at the same moment produce exactly one `create_async_engine` call.
    """
    async with database.engine_with_schema(database.url_for(tmp.mktemp("db"))) as e:
        yield e


@voci.fixture()
async def session(engine: AsyncEngine = Depends(engine)) -> AsyncIterator[AsyncSession]:
    """A real session inside a transaction that is always rolled back.

    Every test sees the real schema and none of its neighbours' writes, so hundreds of database
    tests run safely at once against a single engine.
    """
    async with database.transaction(engine) as s:
        yield s

scope="session" makes engine a run-wide singleton rather than a per-test one — see Scopes for the full set. Depending on it from session is enough to reach it — nothing about the dependent fixture needs to know its dependency is shared.

The rollback itself is ordinary SQLAlchemy, not a voci mechanism: session opens a connection, begins a transaction, and hands the test a session bound to it. Whatever the test does — insert, update, even commit(), since the session joins through a SAVEPOINT — is undone by trans.rollback() when the fixture's async with exits, pass or fail.

@asynccontextmanager
async def transaction(engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
    async with engine.connect() as conn:
        trans = await conn.begin()
        async with AsyncSession(
            bind=conn,
            expire_on_commit=False,
            join_transaction_mode="create_savepoint",
        ) as session:
            yield session
        await trans.rollback()

On SQLite specifically, the driver's own autocommit heuristics fight the rollback unless you take over BEGIN yourself — two sqlalchemy.event listeners on connect and on begin, applied once when the engine is built. A suite on Postgres or another server-backed database doesn't need them; they exist here because SQLite needs no server of its own — nothing beyond uv sync && voci.

The full fixtures, plus the SQLAlchemy plumbing behind them, are in examples/01-fastapi-crud/tests/fixtures.py and examples/01-fastapi-crud/tests/database.py.