Skip to content
Chapter 88Lesson 2

One database per worker

Isolate Vitest's integration tests by routing one disposable Postgres per worker with VITEST_POOL_ID and globalSetup, on Docker or Neon.

Last lesson you built withRollback, and every test it wraps calls db.transaction(...). What real Postgres does db connect to? Not production, where a stray write could drop the database. Not one shared test database either: Vitest runs your test files in parallel, each in its own worker, so pointing them all at one Postgres makes them collide. One worker migrates while another queries half-built tables; a SELECT ... FOR UPDATE lock in one file blocks a query in another. You get a suite that passes alone and fails when the order shifts, with nothing in the diff to explain it.

The fix is one isolated database per worker, routed by VITEST_POOL_ID: create the databases up front, migrate each once, and let each worker own its database for the whole run. Local uses Docker Compose; CI keeps the same shape and only swaps what provides the database.

Vitest’s integration project runs each *.int.test.ts file in a pool of workers, OS threads by default for a Node project. A worker is long-lived: it boots once and runs many test files in sequence. Each worker carries an env var, VITEST_POOL_ID , an integer fixed for the whole run: every test on worker 2 reads VITEST_POOL_ID=2, start to finish.

That fixed integer is the routing key. You create databases test_w1 through test_wN once, then have each worker connect to test_w{VITEST_POOL_ID} and own it for the run. Worker 1 talks only to test_w1, worker 3 only to test_w3. They share no connection, sequence, or lock, so they can’t interfere.

globalSetupruns once, main processVitest workersPer-worker databasesWorker 1VITEST_POOL_ID=1runs files 1..kWorker 2VITEST_POOL_ID=2runs files 1..kWorker 3VITEST_POOL_ID=3runs files 1..ktest_w1test_w2test_w3 connects toconnects toconnects to createscreatescreates

globalSetup creates one database per worker up front; VITEST_POOL_ID routes each worker to its own.

Why per-worker, rather than a finer granularity?

  • A database per test means thousands of heavyweight CREATE DATABASE and DROP DATABASE cycles per run, more time spent provisioning than testing. Unusable.
  • A database per file drops that to hundreds, but each one runs your full migration stack. Hundreds of migration runs is still an order of magnitude too slow.
  • A database per worker lands at exactly maxWorkers migration runs, four to eight on a typical machine. Each worker migrates its database once, then reuses it across every file it runs.

This stacks on last lesson’s per-test rollback: the per-worker database isolates files from each other, while withRollback isolates tests within one worker’s database.

globalSetup vs setupFiles: once per run vs once per worker

Section titled “globalSetup vs setupFiles: once per run vs once per worker”

The two scopes run in different places at different times, and each task belongs in exactly one of them.

  • globalSetup runs once, in Vitest’s main process, before the suite starts and again after it ends. It runs outside every worker, so it has no test globals and no VITEST_POOL_ID. This is the scope for run-level work: bring up Docker, connect to Postgres as a superuser, and CREATE DATABASE test_w1 .. test_wN. On teardown, optionally drop them.
  • setupFiles runs once per worker, inside the worker, before that worker’s test files. The worker is live here, so VITEST_POOL_ID is set. This is the scope for per-worker work: connect to this worker’s database, run migrations, insert the baseline seed, and register the MSW server and the withRollback machinery.

Get the scope wrong and it breaks: creation in setupFiles makes N workers race to create the same databases, and migrations in globalSetup have no VITEST_POOL_ID to tell them which database to target. Sort each task by when in the run it happens exactly once:

TaskglobalSetup (once for the run)setupFiles (once per worker)
Bring up the Docker Postgres
CREATE DATABASE test_w1 .. test_wN
Connect to this worker’s database
Run migrate() against the schema
Insert the baseline seed org and user
server.listen() for MSW
Drop the test databases

You already have a root vitest.config.ts with an integration project from the runner lesson; add two keys to it, one for each scope.

vitest.config.ts
// inside test.projects of vitest.config.ts
{
test: {
name: 'integration',
environment: 'node',
include: ['src/**/*.int.test.ts'],
globalSetup: ['./src/test/db/global-setup.ts'],
setupFiles: ['./src/test/db/setup.ts'],
maxWorkers: 4,
},
},

maxWorkers is a flat key on the test block. Older guides put the worker count at poolOptions.threads.maxThreads, but Vitest 4 removed poolOptions and flattened it to maxWorkers (env override: VITEST_MAX_WORKERS). VITEST_POOL_ID is unchanged: the per-worker index, still always ≤ maxWorkers.

src/test/db/global-setup.ts
import { Client } from 'pg';
const WORKER_COUNT = 4;
export default async function setup() {
const admin = new Client({ connectionString: process.env.DATABASE_URL });
await admin.connect();
for (let id = 1; id <= WORKER_COUNT; id++) {
await admin.query(`DROP DATABASE IF EXISTS test_w${id}`);
await admin.query(`CREATE DATABASE test_w${id}`);
}
await admin.end();
return async () => {
// teardown after the whole suite
};
}

Once, in the main process, before any worker. It connects as superuser, recreates each test_w{id} so the run starts clean, and returns a teardown that drops them.

Sort each setup task into the scope it belongs to. Ask: does it happen once for the whole run, or once inside each worker? Drag each item into the bucket it belongs to, then press Check.

globalSetup Once, in the main process
setupFiles Once per worker
Bring up the Docker Postgres
CREATE DATABASE test_w1..N
Drop the test databases at the end
Connect to this worker’s database
Run migrate() against the schema
Insert the seed baseline org and user
server.listen() for MSW

Import migrate from drizzle-orm/node-postgres/migrator and run it against the worker’s connection, pointed at the same drizzle/ folder production migrates from:

await migrate(db, { migrationsFolder: 'drizzle' });

There’s no separate test schema: the test database is the production schema, built from the exact migration files that build production. When a migration adds a NOT NULL column or a unique constraint, your tests run against it automatically, which catches the schema-drift class of bug a mocked database never can.

Two details make migration reliable. Migrate once per worker, not once per file: a worker runs many files, and re-migrating before each returns the per-file cost this topology avoids, so put the migrate in a beforeAll that runs once per worker. Open the connection lazily, not at import time: a top-level const db = drizzle(...) opens the pool at module-evaluation time, which can fire before globalSetup has run CREATE DATABASE, so you connect to a database that doesn’t exist yet and crash. Open the pool inside beforeAll or behind a memoized getter, which runs late enough that the worker’s database already exists.

import { beforeAll } from 'vitest';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { getWorkerDb } from './worker-db';
import { seedBaseline } from './seed';
beforeAll(async () => {
const workerId = process.env.VITEST_POOL_ID;
const db = getWorkerDb(workerId);
await migrate(db, { migrationsFolder: 'drizzle' });
await seedBaseline(db);
});

beforeAll fires once per worker, and getWorkerDb opens the pool lazily on first call, after globalSetup has created this worker’s database.

import { beforeAll } from 'vitest';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { getWorkerDb } from './worker-db';
import { seedBaseline } from './seed';
beforeAll(async () => {
const workerId = process.env.VITEST_POOL_ID;
const db = getWorkerDb(workerId);
await migrate(db, { migrationsFolder: 'drizzle' });
await seedBaseline(db);
});

VITEST_POOL_ID is the routing key: worker 2 reads 2, getWorkerDb builds the URL for test_w2, and this worker now talks only to its own database.

import { beforeAll } from 'vitest';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { getWorkerDb } from './worker-db';
import { seedBaseline } from './seed';
beforeAll(async () => {
const workerId = process.env.VITEST_POOL_ID;
const db = getWorkerDb(workerId);
await migrate(db, { migrationsFolder: 'drizzle' });
await seedBaseline(db);
});

Migrate once, against the same drizzle/ folder production uses. The test database is the production schema.

import { beforeAll } from 'vitest';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { getWorkerDb } from './worker-db';
import { seedBaseline } from './seed';
beforeAll(async () => {
const workerId = process.env.VITEST_POOL_ID;
const db = getWorkerDb(workerId);
await migrate(db, { migrationsFolder: 'drizzle' });
await seedBaseline(db);
});

With the schema in place, insert the baseline of one org and one user, once per worker right after migrate. The next section covers it.

1 / 1

Drizzle Kit’s migrate applies migration files to a database; check detects drift, failing if the schema and the migration journal disagree because a developer edited the schema without generating a migration. Run check before the suite and in CI to fail that push at the seam, before the drift reaches anyone else:

Terminal window
pnpm drizzle-kit check

One baseline seed, then per-test factories

Section titled “One baseline seed, then per-test factories”

The worker inserts one tiny seed: a single seed_org and seed_admin_user. A test that doesn’t care about org or user setup still needs a valid foreign-key target to hang an invoice on, and the seed gives every test that minimal valid world.

Both rows get fixed IDs, exported as constants so tests reach them by name instead of querying.

src/test/db/seed.ts
export const SEED_ORG_ID = 'seed_org';
export const SEED_ADMIN_USER_ID = 'seed_admin_user';
export const seedBaseline = async (db: DbOrTx) => {
await db
.insert(organizations)
.values({ id: SEED_ORG_ID, name: 'Seed Org' });
await db.insert(users).values({
id: SEED_ADMIN_USER_ID,
orgId: SEED_ORG_ID,
email: 'admin@seed.test',
role: 'admin',
});
};

Heavy, realistic data is a separate concern: drizzle-seed fills a database you want to browse in the app, not test setup.

Per-test rows come from factories like buildInvoice({ status: 'paid' }, tx), the pattern from the unit-testing chapter, called inside the test’s transaction. A factory row written on tx rolls back when the test ends. The seed is committed outside any test transaction and shared read-only across every test the worker runs.

That sharing is safe only if seed rows stay immutable. A write to a seed row commits, since the seed lives outside tx, and the change then leaks into every later test on that worker. So a test that needs to change a row builds its own with a factory and mutates that.

Sort each piece of test data into where it belongs. Ask: is it a shared, read-only baseline, or data this one test builds and asserts on? Drag each item into the bucket it belongs to, then press Check.

Baseline seed Committed once per worker, read-only
Per-test factory Built inside tx, rolls back
The seed_org every test hangs rows on
The seed_admin_user that owns the org
A paid invoice a test asserts gets archived
Three overdue invoices a reminder test scans
An invoice the test mutates and re-reads
A valid FK target a test doesn’t otherwise care about

The .env.test surface and the production-URL guard

Section titled “The .env.test surface and the production-URL guard”

A careless connection string here doesn’t make a test flaky, it drops production tables.

The test harness loads a dedicated .env.test, and only .env.test; production .env and .env.local are never loaded. That exclusion is the first line of defense. Inside live two values: a base DATABASE_URL pointing at the superuser globalSetup uses to create databases, and a WORKER_DATABASE_URL_PATTERN like postgres://test:test@localhost:5433/test_w{id} that each worker fills in with its VITEST_POOL_ID to reach its own database.

Point that DATABASE_URL at production, or let a stray .env.local shadow it with a production URL, and the first pnpm vitest run does exactly what globalSetup is built to do: DROP DATABASE and recreate, or migrate, against production. Rollback does not save you, because CREATE DATABASE and migrate run outside any test transaction.

So the guard goes at the very top of globalSetup and refuses to run unless the URL looks like a test target:

src/test/db/global-setup.ts
const url = process.env.DATABASE_URL ?? '';
if (!url.includes('localhost:5433') && !isNeonBranch(url)) {
throw new Error(
`Refusing to run tests against a non-test database: ${url}`,
);
}

Read it as an allow-list, not a port check: local Docker answers on localhost:5433, a Neon CI branch passes isNeonBranch, and the GitHub Actions sidecar below answers on localhost:5432. Each recognized disposable database joins the list as you wire that environment. The one URL on no list is your production database, and that is the one the guard refuses.

Because the test database is recreated every run, it has nothing worth surviving a crash, so you can turn off the durability a real database works hardest to guarantee. The switch is fsync off, alongside synchronous_commit=off and full_page_writes=off, baked into the test Docker image and never set on a server holding real data. The database then runs roughly an order of magnitude faster, real wall-clock time once you multiply it across every worker’s migrate-and-seed and hundreds of transactions. That same disposability is what makes the guard mandatory.

.env.test
DATABASE_URL=postgres://test:test@localhost:5433/postgres
WORKER_DATABASE_URL_PATTERN=postgres://test:test@localhost:5433/test_w{id}

The test connection surface, nothing from production. The base superuser URL for globalSetup, and the per-worker pattern each worker fills in with its VITEST_POOL_ID.

Only the way Postgres is provided changes from local; the per-worker shape is identical. Matrix builds, caching, and the JUnit reporter belong to the later CI chapter; this is the minimal job.

GitHub Actions runs a sidecar container alongside your job through services. Declare a Postgres 17 service, install with a frozen lockfile, and run the integration project, which migrates each worker’s database itself just as it does locally:

.github/workflows/test.yml
jobs:
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm vitest run --project integration
env:
DATABASE_URL: postgres://test:test@localhost:5432/postgres

Declaring services.postgres makes the container start, not accept connections. Without --health-cmd pg_isready, your migrate step races the container’s boot and fails intermittently on connection-refused. The health-check gates on readiness, not existence, the same lesson the lazy-open taught.

Put if: always() on any cleanup step so a failed run still tears down its resources instead of leaking them into the next one.

WhenCommand
Local, while you codevitest --project integration (watch)
CI, every pushvitest run --project integration
Pre-push hook, only affected filesvitest run --project integration --changed

When to swap Docker for a Neon branch per CI run

Section titled “When to swap Docker for a Neon branch per CI run”

The trigger is the seed step getting slow. The baseline seed is tiny by design, but some suites need production-shaped data to assert on: real row counts that exercise pagination, or the data depth that row-level-security policies act on. Once building that volume on every run pushes your local seed step past roughly five seconds, the empty-Docker default has stopped being cheap. Until then, services: postgres wins.

Neon forks a full database, schema and data, from a parent such as a staging branch using copy-on-write : the branch shares the parent’s storage pages until something writes. Every CI run gets its own isolated, full-data copy of staging in under a second, runs against it, and throws it away. The per-worker shape is unchanged; only the substrate swaps.

A create-branch action at job start forks the branch and exposes its connection string as the db_url output, fed in as the run’s DATABASE_URL. A delete-branch action on if: always() tears it down when the job ends, on success or failure.

.github/workflows/test.yml
steps:
- uses: actions/checkout@v4
- uses: neondatabase/create-branch-action@v6
id: branch
with:
project_id: ${{ vars.NEON_PROJECT_ID }}
parent: staging
api_key: ${{ secrets.NEON_API_KEY }}
- run: pnpm install --frozen-lockfile
- run: pnpm vitest run --project integration
env:
DATABASE_URL: ${{ steps.branch.outputs.db_url }}
- if: always()
uses: neondatabase/delete-branch-action@v3
with:
project_id: ${{ vars.NEON_PROJECT_ID }}
branch_id: ${{ steps.branch.outputs.branch_id }}
api_key: ${{ secrets.NEON_API_KEY }}

The driver may differ, local Docker on node-postgres and Neon CI on the serverless driver, but your test code never changes, because it talks to db and tx, not the driver underneath: the explicit-handle seam from the last lesson paying off again. The cost is real too: Neon’s free-tier branch limits can queue concurrent CI runs against each other, which is itself the signal to upgrade the plan.

Which CI database substrate?

maxWorkers is a performance dial, not a correctness one: because each worker owns its own database and shares no mutable state, changing the count only moves throughput, never results. Start at Math.min(4, cpuCount) locally and go higher on CI runners with more cores. More workers means more startup migrations and more connections contending over one Postgres, so the gains flatten past a knee around four to eight; measure your own suite rather than memorize a number.

A new integration run just dropped three production tables on its very first execution, because a stray config pointed it at the production database. Which single change would have stopped it?

Have globalSetup throw on its first line unless DATABASE_URL matches a known test target like localhost:5433.
Wrap each test in withRollback so every write is discarded when the test ends.
Set fsync=on so commits are flushed to disk and nothing is lost.
Bump maxWorkers so each worker touches a smaller slice of the schema.

Next you’ll cross one more boundary, the network, replacing the mocked SDK with real HTTP intercepted at the wire so your serialization, signing, and parsing go under test.