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.
Database lifecycle strategy
Section titled “Database lifecycle strategy”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.
A fresh server per test file
Section titled “A fresh server per test file”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});Prepare once for the run
Section titled “Prepare once for the run”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.
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();}import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globalSetup: ['./test/global-setup.ts'], setupFiles: ['@pgmem/core/register'], },});module.exports = { globalSetup: './test/global-setup.js', globalTeardown: './test/global-teardown.js', testEnvironment: '@pgmem/core/jest-environment',};const { PgmemServer } = require('@pgmem/core');
module.exports = async () => { globalThis.pgmem = await PgmemServer.start({ database: 'app', prepare: ({ url }) => migrate(url) }); Object.assign(process.env, globalThis.pgmem.env());};module.exports = () => globalThis.pgmem.close();The environment gives each Jest worker one fork and resets it between the test files that worker runs. It needs jest-environment-node, which Jest installs.
import { PgmemServer } from '@pgmem/core';
let pg;export async function globalSetup() { pg = await PgmemServer.start({ database: 'app', prepare: ({ url }) => migrate(url) }); Object.assign(process.env, pg.env());}export async function globalTeardown() { await pg.close();}node --test --test-global-setup=./test/global-setup.mjs --import @pgmem/core/registerGlobal setup needs Node.js 24 or newer. Each test file runs in its own process, so --import gives every file its fork.
import { spawnSync } from 'node:child_process';import { PgmemServer } from '@pgmem/core';
const pg = await PgmemServer.start({ database: 'app', prepare: ({ url }) => migrate(url) });const run = spawnSync('bun', ['test', '--isolate', '--preload', '@pgmem/core/register'], { stdio: 'inherit', env: { ...process.env, ...pg.env() },});await pg.close();process.exit(run.status ?? 1);bun test has no global setup and shares one process by default, so a small script starts pgmem and runs the tests with --isolate.
@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);});Isolate only tests that write
Section titled “Isolate only tests that write”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.
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 });});const { currentFork } = require('@pgmem/core');
describe('tests that write', () => { afterEach(() => currentFork().reset()); test('starts from the prepared database', async () => { // write to the database });});import { afterEach, describe, it } from 'node:test';import { currentFork } from '@pgmem/core';
describe('tests that write', () => { afterEach(() => currentFork().reset()); it('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.
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'); });});Fork capacity and waiting
Section titled “Fork capacity and waiting”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.
Things to know
Section titled “Things to know”- 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; useSET 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. resetwaits for open transactions and fails with codebusyaftertimeoutMs(default 5 s).- Forks belong to the process that made them through
@pgmem/core/register,fork()orwithFork(), and close when it exits.