Resetting State Between Tests Without Slowing CI

Every integration test mutates shared state, and every mutation must be undone before the next test runs, or independence collapses. The naive answer — tear everything down and rebuild it between each test — is correct and catastrophically slow, turning a two-minute suite into a twenty-minute one. This guide is for engineers running Vitest 3.x integration suites who need each test isolated but cannot afford a full reset per test. We compare the three practical strategies — transaction rollback, targeted truncation, and per-worker databases — establish which guarantee each provides and what each costs, and show how to combine them so the fast path handles the common case while a slower path covers the tests that genuinely need it. The goal is the cheapest reset that still guarantees independence, chosen per suite rather than adopted globally, and it complements the seeding workflow in seeding a test database for integration tests.

Root Cause Analysis

The cost of resetting state is dominated by durability: anything that hits disk is slow. Truncating tables and reinserting a baseline forces write-ahead-log flushes, index rebuilds, and vacuum pressure, and doing it hundreds of times per run makes teardown, not the code under test, the pipeline’s bottleneck. A transaction that rolls back, by contrast, discards its changes in memory without ever committing them to durable storage, so the reset is nearly free. The root-cause question for any reset strategy is therefore: how much durable work does it perform per test, and can that work be avoided without losing isolation?

The competing pressure is fidelity. Transaction rollback is fast precisely because the writes never commit, but that means the code under test runs inside an open transaction — and any code that itself manages transactions, or expects its writes to be visible to a second connection, will not behave as it does in production. So the fast path is not universally applicable; it trades a slice of fidelity for a large speed gain, and tests that need full commit semantics must fall back to a slower, higher-fidelity reset. Getting this trade-off right is the same kind of cost-versus-confidence reasoning that drives a sound test pyramid strategy: spend the expensive isolation only where it buys real coverage, and use the cheap one everywhere else.

A third pressure hides behind the first two: completeness. It is tempting to think of state as living only in the database, but a test also mutates module-level caches, singleton clients, mocked timers, and any in-memory request handlers you registered. A reset that scrubs the database but forgets the in-memory surfaces produces a subtler order-dependence — one that survives every database check yet still leaks between tests through a cached value or a leftover handler. The correct mental model is that every surface a test can write to must be restored by the same teardown, so completeness, not just speed, is a property you have to design for. The strategies below therefore pair each database reset with an explicit in-memory reset rather than treating the database as the whole story.

Speed versus fidelity across three reset strategies Transaction rollback is fastest but lowest fidelity, truncation is slower with full commit semantics, and per-worker databases are highest fidelity and provisioning cost. Cheaper and faster ← → higher fidelity Transaction rollback no disk write fastest reset no commit semantics Truncation + reseed durable writes full commit semantics slower per test Per-worker database full isolation true parallelism provisioning cost pick the leftmost strategy each test can tolerate
Three reset strategies on a speed-versus-fidelity spectrum; choose the cheapest each test tolerates.

Reproducible Setup

Reuse the disposable Postgres and schema from the seeding guide. Add a small helper that lets a suite opt into a reset strategy.

npm install -D vitest pg
export TEST_DATABASE_URL="postgres://postgres:test@localhost:5433/postgres"
// src/db.ts
import { Pool } from 'pg';
export const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
// tests/reset/strategies.ts — the three resets behind one interface
import { pool } from '../../src/db';
import { seed } from '../seed';

export const rollback = {
  before: () => pool.query('BEGIN'),
  after: () => pool.query('ROLLBACK'),
};

export const truncate = {
  before: async () => {},
  after: async () => {
    await pool.query('TRUNCATE posts, users RESTART IDENTITY CASCADE');
    await seed();
  },
};

Implementation

Step 1 — Make transaction rollback the default reset. Register the rollback hooks globally so every integration test is isolated at near-zero cost. This handles the large majority of tests that only read and write through one connection.

// tests/setup/rollback.ts
import { beforeEach, afterEach } from 'vitest';
import { rollback } from '../reset/strategies';

beforeEach(rollback.before);
afterEach(rollback.after);

Step 2 — Provide a truncation opt-out for tests that must commit. A test that exercises code which manages its own transactions cannot run inside an outer transaction, so it opts into truncation. Scope the override to that file with a local hook rather than changing the global default.

// tests/outbox.commit.test.ts
import { beforeEach, afterEach, it, expect } from 'vitest';
import { truncate } from './reset/strategies';
import { pool } from '../src/db';
import { processOutbox } from '../src/outbox';

beforeEach(truncate.before);
afterEach(truncate.after);

it('commits outbox rows so a second connection sees them', async () => {
  await processOutbox(); // opens and commits its own transaction
  const { rows } = await pool.query("SELECT count(*)::int AS n FROM posts WHERE published");
  expect(rows[0].n).toBeGreaterThan(0);
});

Step 3 — Parallelise with per-worker schemas. When throughput matters, give each Vitest worker its own schema so transactions never contend. Set the search path once per worker in a setup file keyed on the pool id.

// tests/setup/worker-schema.ts
import { beforeAll } from 'vitest';
import { pool } from '../../src/db';
import { migrate } from '../../src/db-migrate';
import { seed } from '../seed';

beforeAll(async () => {
  const schema = `test_w${process.env.VITEST_POOL_ID ?? '0'}`;
  await pool.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
  await pool.query(`SET search_path TO ${schema}`);
  await migrate();
  await seed();
});

Step 4 — Wire the strategies into config. Use forks so each worker has an isolated process and its own connection, and register the rollback default alongside the per-worker schema setup.

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

export default defineConfig({
  test: {
    pool: 'forks',
    poolOptions: { forks: { maxForks: 4 } },
    setupFiles: ['./tests/setup/worker-schema.ts', './tests/setup/rollback.ts'],
  },
});

Step 5 — Reset in-memory state too, not just the database. Module-level caches, singletons, and MSW handlers are state as well. Reset them in the same hooks so a test never inherits another’s in-memory mutations; MSW’s resetHandlers is the canonical example.

// tests/setup/memory.ts
import { afterEach } from 'vitest';
import { server } from '../mocks/server'; // an MSW v2 setupServer instance

afterEach(() => {
  server.resetHandlers(); // drop per-test request overrides
});
Every mutable surface a reset must cover A complete reset restores the database, module caches, mocked timers, and MSW request handlers, not just the database. Reset every surface, not just the database database rollback / truncate module caches clear singletons mocked timers restore real clock MSW handlers resetHandlers() a test that leaks any one of these is order-dependent
Independence requires resetting in-memory state alongside the database.
Choosing a reset strategy per test If the test needs commit semantics use truncation, if it needs parallel throughput use a per-worker schema, otherwise use fast transaction rollback. Needs its own COMMIT? Rollback default, fastest Truncate full commit Per-worker schema for parallel throughput layered on either no yes a per-worker schema wraps either reset to unlock real parallelism
Default to rollback; escalate to truncation for commit semantics and per-worker schemas for throughput.

Verification

Prove two things: that each strategy actually isolates, and that the fast one is meaningfully faster. Isolation is verified by a pair of tests that would collide if state leaked; speed is verified by timing the two resets over many iterations.

// tests/isolation.test.ts
import { it, expect } from 'vitest';
import { pool } from '../src/db';
import { makeUser } from '../factories/user';

async function addAdmin(tag: string) {
  const u = makeUser({ id: `iso-${tag}`, email: `${tag}@example.test`, role: 'admin' });
  await pool.query('INSERT INTO users (id,email,role,created_at) VALUES ($1,$2,$3,$4)',
    [u.id, u.email, u.role, u.createdAt]);
}

it('first test adds an admin', async () => {
  await addAdmin('a');
  const { rows } = await pool.query("SELECT count(*)::int AS n FROM users WHERE role='admin'");
  expect(rows[0].n).toBe(2); // baseline admin + this one
});

it('second test does not see the first test admin', async () => {
  const { rows } = await pool.query("SELECT count(*)::int AS n FROM users WHERE role='admin'");
  expect(rows[0].n).toBe(1); // rollback discarded the previous insert
});

Run npx vitest run; both must pass regardless of order. To confirm the speed difference, run the suite once with the rollback setup and once with the truncate setup and compare wall-clock time — on any non-trivial baseline the rollback run should be several times faster. Finally, run with --pool.forks.maxForks=4 and check that per-worker schemas keep the isolation tests green under parallelism.

Troubleshooting

Symptom: rollback tests intermittently see each other’s data. Diagnosis: the code under test acquired a different connection from the pool than the one the BEGIN/ROLLBACK wraps, so its writes were on an uncontrolled connection. Fix: force the code under test and the reset hooks to share a single dedicated connection for the duration of each test, rather than each drawing from the pool independently.

Symptom: a test that calls COMMIT internally hangs or errors under rollback. Diagnosis: it is running inside the outer transaction the rollback strategy opened, and nested commit semantics do not match. Fix: opt that file into the truncation strategy so it runs without an enclosing transaction and can manage its own; keep it out of the global rollback default.

Symptom: parallel workers are slower than serial. Diagnosis: all workers share one schema, so their writes contend on the same rows and locks serialise them anyway, adding coordination overhead on top. Fix: give each worker its own schema keyed on VITEST_POOL_ID, or lower maxForks if the database cannot sustain the connection count; measure before and after to confirm the speedup is real.

FAQ

Is transaction rollback always safe to use?

It is safe whenever the code under test does not manage its own transactions and does not require a second connection to observe its writes. That covers most repository-style code that reads and writes through one connection. It breaks for code that commits internally, spans connections, or asserts on cross-connection visibility, and those cases should opt into truncation. Treat rollback as the default and truncation as the deliberate exception.

How do I reset non-database state like caches or timers?

Put those resets in the same afterEach that handles the database so nothing is forgotten. Clear module-level caches, reset MSW handlers with resetHandlers, restore mocked timers, and null out any singletons. The principle is that any state a test can mutate is state the reset must restore; the database is just the most expensive instance of it, not the only one.

Do I need per-worker schemas if I use rollback?

Only if you want true parallelism. Rollback isolates tests within a single connection, but if all workers share one connection or schema, their transactions still contend. Per-worker schemas remove that contention so workers run independently. For a small suite the provisioning cost may not pay off; for a large one it is usually the single biggest throughput win, layered on top of whichever reset each test uses.

What about SQLite in-memory databases?

An in-memory SQLite database per worker is an extremely fast reset because dropping and recreating it costs almost nothing, and it sidesteps the disk cost entirely. The caveat is fidelity: SQLite’s SQL dialect and constraint behaviour differ from Postgres, so tests that pass against in-memory SQLite may not reflect production. Use it when speed dominates and dialect differences do not touch the code under test; otherwise prefer a per-worker Postgres schema.