Skip to content

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.

Use Node.js 24 or newer for this global setup example. Install the packages, save these two files, and run the command below:

Terminal window
npm install --save-dev @pgmem/core pg
test/global-setup.mjs
import 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(); }
test/orders.test.mjs
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);
});
Terminal window
node --test --test-global-setup=./test/global-setup.mjs --import @pgmem/core/register test/*.test.mjs

The 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.

Keep service wrappers in one application-owned helper, so each test names its intent once:

test/helpers.mjs
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:

test/environment.cjs
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.

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.

test/user-repository.test.ts
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();
});

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:

test/user-repository.test.ts
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 });

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');
});
});

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():

test/global-setup.ts
Object.assign(process.env, pg.env());
process.env.PGMEM_TEMPLATE_URL = pg.url;
vitest.config.ts
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'] } },
],
},
});
test/use-template.ts
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.

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.

  • 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.
  • 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.

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.

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.