Node.js unit tests
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 does not move a client that has already been created.
For a long-lived HTTP app, see API tests or Playwright E2E tests.
See the lifecycle guide and runnable application examples.
Quickstart with node:test
Section titled “Quickstart with node:test”Use Node.js 24 or newer for this global setup example. Install the packages, save these two files, and run the command below:
npm install --save-dev @pgmem/core pgimport pg from 'pg';import { PgmemServer } from '@pgmem/core';
let server;export async function globalSetup() { server = await PgmemServer.start({ async prepare({ url }) { const pool = new pg.Pool({ connectionString: url }); try { await pool.query('CREATE TABLE orders(id int)'); } finally { await pool.end(); } }, }); Object.assign(process.env, server.env());}export async function globalTeardown() { await server.close(); }import { test, after } from 'node:test';import assert from 'node:assert/strict';import pg from 'pg';import { withTestReset } from '@pgmem/core';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });after(() => pool.end());
test('creates an order', withTestReset(async () => { await pool.query('INSERT INTO orders VALUES (1)'); const { rows } = await pool.query('SELECT count(*)::int AS n FROM orders'); assert.equal(rows[0].n, 1);}));
test('reads the prepared database', async () => { const { rows } = await pool.query('SELECT count(*)::int AS n FROM orders'); assert.equal(rows[0].n, 0);});node --test --test-global-setup=./test/global-setup.mjs --import @pgmem/core/register test/*.test.mjsThe pool is built once per file. Only the writing test pays a reset. withTestReset(fn, { snapshot, timeoutMs }) can restore a seeded baseline; it preserves both errors if the callback and reset fail. Overlapping wrapped callbacks on one fork are rejected. Concurrent writing tests need withFork and clients built from their own URLs.
Compose services in your project
Section titled “Compose services in your project”Keep service wrappers in one application-owned helper, so each test names its intent once:
import { withTestReset } from '@pgmem/core';
export const writeTestBody = (fn) => withTestReset(fn);// When another service is available:// export const writeTestBody = (fn) => withValkeyTest(withTestReset(fn));import { test } from 'node:test';import { writeTestBody } from './helpers.mjs';
test('writes through the application', writeTestBody(async () => { // exercise the application}));Each service owns its endpoint, setup and cleanup. Use distinct environment variable names; PGMEM_ENV or useFork({ env: [...] }) controls pgmem’s names. Set all service endpoints before importing application clients. A combined global setup should close previously started services if a later service fails, and run all cleanup functions even if one fails.
For Jest, keep an existing environment and add pgmem with a factory:
const { TestEnvironment } = require('jest-environment-node');const { withPgmemEnvironment } = require('@pgmem/core/jest-environment-factory');
module.exports = withPgmemEnvironment(TestEnvironment);// A second service can wrap this class or be passed as the base class.Set testEnvironment: './test/environment.cjs' in Jest config. The factory calls the base environment’s setup and cleanup; it also cleans up on setup failure. Pass { env: ['APP_DATABASE_URL'], forkTimeoutMs: 30000, resetTimeoutMs: 5000 } as its second argument when needed. The existing @pgmem/core/jest-environment entry remains the ready-made Node environment.
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.
Route existing pg clients with shadowPg
Section titled “Route existing pg clients with shadowPg”shadowPg(test, name, callback) redirects pg Client and Pool connections to a fresh pgmem fork for each case. The application’s connection string and DATABASE_URL stay unchanged. A pool or Prisma client using @prisma/adapter-pg can be constructed before the test; its next connection is routed to the active fork. The prepared snapshot still comes from global setup. Use { fork: false } only for read-only tests.
import { test, expect } from 'vitest';import { shadowPg } from '@pgmem/core';import { UserRepository } from '../src/user-repository';
const users = new UserRepository(); // unchanged application configuration
shadowPg(test, 'creates a user', async () => { const created = await users.create('ada@example.com', 'Ada'); expect(await users.findByEmail('ada@example.com')).toEqual(created);});
shadowPg(test, 'starts from the prepared snapshot', async () => { expect(await users.findByEmail('ada@example.com')).toBeNull();});import { Pool } from 'pg';
export class UserRepository { private pool = new Pool({ connectionString: process.env.DATABASE_URL });
async create(email: string, name: string) { const { rows } = await this.pool.query( 'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id, email, name', [email, name], ); return rows[0]; }
async findByEmail(email: string) { const { rows } = await this.pool.query( 'SELECT id, email, name FROM users WHERE email = $1', [email], ); return rows[0] ?? null; }}Close the application pool in runner teardown. Do not run tests that share one pool and fork concurrently. Connections checked out before the marker starts cannot be moved. This interception covers pg and clients built on it, including @prisma/adapter-pg; other PostgreSQL drivers need their own adapter.
Per-case routing for clients built inside the test
Section titled “Per-case routing for clients built inside the test”pgmemTest creates a fresh fork for each case by default and temporarily routes DATABASE_URL to it. Build the database client inside the callback; an already constructed client retains its original connection. fork: false reuses one shared fork and is suitable only for read-only cases. The helper serializes its callbacks because process.env is process-wide. Use the register hook and reset() below for clients made at import time.
Suppose UserRepository owns its pool and reads DATABASE_URL in its constructor. The test constructs the repository inside the routed callback and checks its public behavior:
import { test, expect } from 'vitest';import { pgmemTest } from '@pgmem/core';import { UserRepository } from '../src/user-repository';
pgmemTest(test, 'creates and finds a user', async () => { const users = new UserRepository(); try { const created = await users.create('ada@example.com', 'Ada'); expect(await users.findByEmail('ada@example.com')).toEqual(created); } finally { await users.close(); }});
pgmemTest(test, 'returns null for an unknown user', async () => { const users = new UserRepository(); try { expect(await users.findByEmail('missing@example.com')).toBeNull(); } finally { await users.close(); }}, { fork: false });import { Pool } from 'pg';
export class UserRepository { private pool = new Pool({ connectionString: process.env.DATABASE_URL });
async create(email: string, name: string) { const { rows } = await this.pool.query( 'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id, email, name', [email, name], ); return rows[0]; }
async findByEmail(email: string) { const { rows } = await this.pool.query( 'SELECT id, email, name FROM users WHERE email = $1', [email], ); return rows[0] ?? null; }
close() { return this.pool.end(); }}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'); });});Share the template across read-only files
Section titled “Share the template across read-only files”In most suites, half the files or more only read. Those files need no fork at all: point them at the template server and keep @pgmem/core/register for the files that write. Read-only files then take no fork slot, and the pool serves only the writing files.
The template is a PostgreSQL cluster like any fork: every connection from every file gets its own backend process, so read-only files run against it in parallel, as they would against a server. Only a file that writes needs a fork of its own.
With Vitest, split the files into two projects. The global setup exports the template URL next to server.env():
Object.assign(process.env, pg.env());process.env.PGMEM_TEMPLATE_URL = pg.url;import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globalSetup: ['./test/global-setup.ts'], projects: [ { test: { name: 'read', include: ['test/read/**/*.test.ts'], setupFiles: ['./test/use-template.ts'] } }, { test: { name: 'write', include: ['test/write/**/*.test.ts'], setupFiles: ['@pgmem/core/register'] } }, ], },});process.env.DATABASE_URL = process.env.PGMEM_TEMPLATE_URL;Jest’s projects with testMatch and setupFiles work the same way. With node:test, run the two directories as two commands and pass --import @pgmem/core/register only to the writing one.
Fork capacity and waiting
Section titled “Fork capacity and waiting”maxForks on PgmemServer.start() caps the default snapshot’s live forks; the default comes from memory: a quarter of the server process’s memory limit divided by a fork’s cost (shared_buffers plus about 32 MB), 28 on a 7 GB CI runner. 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. Automatic acquisition has a 30 second default, configured by forkTimeoutMs or PGMEM_FORK_TIMEOUT_MS. Reused worker resets have a separate 5 second default, configured by resetTimeoutMs or PGMEM_RESET_TIMEOUT_MS.
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 and reset timeouts.
Things to know
Section titled “Things to know”- A fork is a PostgreSQL cluster of its own: every connection gets a backend process, so pools, locks between connections and deadlock detection behave as on a server.
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.
Safe parallel execution
Section titled “Safe parallel execution”Run separate test files in separate runner workers; @pgmem/core/register gives each file its own fork. Within one file, writing cases that use its shared fork or one application Pool must run sequentially. shadowPg keeps a target in AsyncLocalStorage, but it cannot make an already checked-out connection from a shared Pool serve two forks at once; give concurrent cases separate application Pool instances, or move them to separate worker files. pgmemTest changes process.env and serializes its callbacks, so do not use it as a way to make shared application state concurrent. Never reset a fork while another case is using it; finish and close database work before case teardown.
Declare service environment ownership
Section titled “Declare service environment ownership”Every service can use @pgmem/core/environment, which is a small helper independent of test runners. A second service claiming the same variable fails with ServiceEnvConflict before any values change. Services using direct assignments cannot be identified by the registry. Use the same target and scope in a Jest realm. Release restores prior values and is idempotent.
import { installServiceEnv } from "@pgmem/core/environment";
const releaseValkey = installServiceEnv("valkey", { VALKEY_URL: cache.url });const releaseSearch = installServiceEnv("opensearch", { OPENSEARCH_URL: search.url });// In a Jest environment:// installServiceEnv("valkey", values, {// target: this.global.process.env, scope: this.global,// });// During teardown, attempt every release even if one throws.useFork({ readOnly: true }), Jest readOnly: true, or PGMEM_READ_ONLY=true guards the injected file URL against ordinary persistent writes. Enable it before importing clients. Use a separate writable fork for writing cases. fork.env(names, { readOnly: true }) also includes PGOPTIONS when using split PGHOST/PGPORT fields.