Skip to content

Database Transaction per Test

Integration tests that touch a real database need to start from a clean state. Truncating tables between every test is slow, so the conventional workaround is to wrap each test in a transaction that's rolled back when it finishes. Nothing ever commits, and there's no per-test cleanup to write.

Vitest exposes this through aroundEach 4.1.0+ and a scoped fixture 3.2.0+ .

Pattern

ts
import { test as baseTest } from 'vitest'
import { createTestDatabase } from './db.ts'

export const test = baseTest
  .extend('db', { scope: 'file' }, async ({}, { onCleanup }) => {
    const db = await createTestDatabase()
    onCleanup(() => db.close())
    return db
  })

test.aroundEach(async (runTest, { db }) => {
  await db.transaction(runTest)
})

test('insert user', async ({ db }) => {
  await db.insert({ name: 'Alice' })
  // rolled back automatically when the test ends
})

How it works

The db fixture is created once per file via scope: 'file', so connection setup happens once instead of on every test, and onCleanup closes the connection when the file is done. aroundEach wraps every test in db.transaction(runTest), and anything the test writes gets rolled back when runTest resolves. The test receives the same db instance through its context, with no awareness that it's running inside a transaction.

This works as long as your database driver supports nested transactions or savepoints, which covers most modern databases. The same aroundEach hook can also wrap an AsyncLocalStorage context if you want to propagate things like tenant or trace IDs through the test alongside the transaction.

One connection per worker

If the suite has many files, paying for a fresh database connection on every file adds up. Switching the fixture to scope: 'worker' and turning off isolation lets multiple files share a single connection per worker process:

vitest.config.ts
ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    isolate: false,
  },
})
ts
import { test as baseTest } from 'vitest'
import { createTestDatabase } from './db.ts'

export const test = baseTest
  .extend('db', { scope: 'worker' }, async ({}, { onCleanup }) => {
    const db = await createTestDatabase()
    onCleanup(() => db.close())
    return db
  })

test.aroundEach(async (runTest, { db }) => {
  await db.transaction(runTest)
})

By default, every test file runs in its own worker, so scope: 'file' and scope: 'worker' behave identically. With isolate: false, Vitest reuses workers across files (capped by maxWorkers), so a worker-scoped fixture is created once per worker instead of once per file. For a suite of 200 files running on 8 workers, that's 8 connections instead of 200.

Reusing workers isn't a free optimization. With isolation off, files share module instances inside the worker, and tests that mutate top-level state (counters, caches, monkey-patched globals) can leak that state to whichever file runs next in the same worker. The per-test rollback handles data isolation in the database. It can't protect module state in the worker. Read the trade-offs in the Per-File Isolation Settings recipe before turning isolation off suite-wide.

vmThreads and vmForks always run isolated regardless of the isolate flag, so worker-scoped fixtures fall back to per-file behavior in those pools.

See also