Skip to content

Testing with Vitest, Jest, node:test and Bun

Node.js test runners load each test file in a fresh worker or module context, and an ORM client reads DATABASE_URL once, when it is created. pgmem starts one process per run and gives each file one fork before its imports; test cases in the file share that fork. Read-only cases need no extra fork or reset. Add that isolation only around cases that write. Changing DATABASE_URL per test case would not help, because the client already holds the URL.

Use a fresh server when a test file needs its own schema. For a shared schema and seed data, prepare one snapshot for the run. The register hook automatically creates one fork per test file; read-only cases share it, and only writing cases need an additional reset or fork. Concurrent writing tests need separate forks.

schema-variant.test.ts
import { PgmemServer } from '@pgmem/core';
import { afterAll, beforeAll, test } from 'vitest';
let server: PgmemServer;
beforeAll(async () => {
server = await PgmemServer.start({
database: 'app',
control: false,
prepare: ({ url }) => applySchemaV2(url),
});
});
afterAll(() => server.close());
test('reads the new column', async () => {
// connect to server.url
});

Start the server in the runner’s global setup and export server.env(). It carries PGMEM_CONTROL and PGMEM_SNAPSHOT, which is how test processes reach the server and its snapshot.

test/global-setup.ts
import { execFileSync } from 'node:child_process';
import { PgmemServer } from '@pgmem/core';
export async function setup() {
const pg = await PgmemServer.start({
database: 'app',
prepare({ url }) {
execFileSync('npx', ['prisma', 'migrate', 'deploy'], {
stdio: 'inherit',
env: { ...process.env, DATABASE_URL: url },
});
},
});
Object.assign(process.env, pg.env());
return () => pg.close();
}
vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globalSetup: ['./test/global-setup.ts'],
setupFiles: ['@pgmem/core/register'],
},
});

@pgmem/core/register forks the snapshot and writes the fork’s URL to DATABASE_URL before the test file’s imports run, so application code that builds its client at import time connects to that file’s copy. This file-level fork is shared by its test cases; only write cases need an additional reset or fork. Test files run in parallel, each on its own fork. Set PGMEM_ENV to write other variable names instead.

Share the file fork across read-only tests

Section titled “Share the file fork across read-only tests”

Tests in one file share that file’s fork. A file whose tests only read needs nothing else:

import { expect, test } from 'vitest';
import { prisma } from '../src/db'; // built from process.env.DATABASE_URL
test('counts users', async () => {
expect(await prisma.user.count()).toBe(0);
});

reset() puts the file’s fork back to its snapshot in place. Place an afterEach hook inside the suite of writing tests to restore it after those cases; read-only tests outside that suite do not pay a reset. The URL and pooled connections stay valid, and prepared statements remain available.

test/orders.test.ts
import { afterEach, describe, test } from 'vitest';
import { currentFork } from '@pgmem/core';
describe('tests that write', () => {
afterEach(() => currentFork().reset());
test('starts from the prepared database', async () => {
// write to the database
});
});

Without isolation, this pair is order-dependent: the second test fails if signUp runs first. The write-only suite above resets the shared fork after the writing case.

test/users.test.ts
import { expect, test } from 'vitest';
import { prisma } from '../src/db';
import { signUp } from '../src/blog';
test('sign up', async () => {
await signUp('alice@example.com');
expect(await prisma.user.count()).toBe(1);
});
test('the next test starts from an empty database', async () => {
expect(await prisma.user.count()).toBe(0);
});

Give a writing suite its own seed baseline. Snapshot the fork after seeding, then reset to that snapshot after each writing case instead of the run’s baseline:

import { afterEach, beforeAll, describe, test } from 'vitest';
import { currentFork, type PgmemSnapshot } from '@pgmem/core';
describe('tests that write', () => {
let seeded: PgmemSnapshot;
beforeAll(async () => {
await seedOrders(process.env.DATABASE_URL!);
seeded = await currentFork().snapshot();
});
afterEach(() => currentFork().reset({ snapshot: seeded }));
test('uses the writing suite seed', async () => {
// write to the database
});
});

Concurrent tests cannot share one fork. Give each its own with withFork, and pass the URL to code that accepts one:

import { withFork } from '@pgmem/core';
import { test } from 'vitest';
test.concurrent('imports a file', async () => {
await withFork(async (fork) => {
const app = createApp({ databaseUrl: fork.url });
await app.importCsv('fixtures/orders.csv');
});
});

maxForks on PgmemServer.start() caps the default snapshot’s live forks; the default is the available CPU count. Every registered test file takes a slot because @pgmem/core/register creates one fork before import. If the pool is full, registration waits until another file’s fork closes. That automatic wait has no deadline.

Explicit fork() and withFork() calls accept timeoutMs; if the slot does not open in time, they fail with PgmemError code pool_timeout.

const pg = await PgmemServer.start({ maxForks: 4 });
await pg.withFork(async (fork) => {
await runImport(fork.url);
}, { timeoutMs: 30_000 });

Each fork adds a separate data-directory copy and buffer cache. See limits for the distinction between fork-slot, snapshot, reset and connection-wait timeouts.

  • A fork is one PostgreSQL session shared by all its connections, the way a transaction-mode pooler shares one. Pools work, but SET, temp tables and advisory locks are shared; use SET LOCAL.
  • Query through the transaction handle inside a transaction callback. A query on another pooled connection would wait for the transaction to end; pgmem ends that wait after waitTimeoutMs (default 2 s) with SQLSTATE 55P03 and a message naming both connections.
  • reset waits for open transactions and fails with code busy after timeoutMs (default 5 s).
  • Forks belong to the process that made them through @pgmem/core/register, fork() or withFork(), and close when it exits.