@pgmem/core
v1.18.2
Published
In-memory PostgreSQL 18 for tests: one DATABASE_URL per test file, reset in place per test, no Docker
Maintainers
Readme
@pgmem/core
A real PostgreSQL that keeps everything in memory, packaged as a single
binary and driven from Node.js. Prepare the schema once per test run, give
every test file its own copy through DATABASE_URL, and put that copy back
between tests without reconnecting.
Application code does not change: Prisma, Drizzle, TypeORM, Kysely, pg
and postgres connect to the URL like to any PostgreSQL, pools included.
How it fits together
- Once per run (global setup):
PgmemServer.start()starts pgmem, runs your migrations against the template database and snapshots it. Exportserver.env()so test processes can reach it. - Per test file:
@pgmem/core/registerforks the snapshot (about 15 ms) and writes the fork's URL toDATABASE_URLbefore the test file is imported, so an ORM client created at import time uses the fork. Test files run in parallel, each on its own fork. - Per test (optional):
currentFork().reset()puts the fork back to the snapshot in place. The URL and pooled connections stay valid. - Concurrent tests in one file:
withFork(fn)hands a separate fork to code that takes a URL.
Setting DATABASE_URL per test case does not move an ORM client that was
already created; it keeps the URL it read during construction.
For a client built inside each test callback, pgmemTest(test, name, fn)
routes the callback to a fresh fork by default and closes it afterwards.
withTestDatabase(fn, { fork: false }) reuses a shared fork for read-only
cases. Both helpers temporarily change process.env, serialize their own
callbacks, and require clients to be created inside the callback. They do
not move an already connected pool or coordinate with unrelated concurrent
code in the same process.
For API tests, register a fork before importing the app, keep the app and
its pool alive, and call currentFork().reset() before each sequential
case. For Playwright E2E tests, start a pgmem server, fork, and app process
per Playwright worker; startTestApp({ fork, args: ['./src/server.js'] })
chooses a port, passes the fork URL to the app, waits for /health, and
closes the process after use. Reset the fork
before each browser test. See the unit,
API, and
E2E guides.
Vitest
// 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()); // PGMEM_CONTROL, PGMEM_SNAPSHOT
return () => pg.close();
}// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globalSetup: ["./test/global-setup.ts"],
setupFiles: ["@pgmem/core/register", "./test/reset.ts"],
},
});// test/reset.ts: only if every test should start from the snapshot
import { beforeEach } from "vitest";
import { currentFork } from "@pgmem/core";
beforeEach(() => currentFork().reset());The global setup is an ordinary function, so servers of other kinds (osmem, valkeymem, ...) start next to pgmem in the same file.
Jest
// jest.config.js
module.exports = {
globalSetup: "./test/global-setup.js",
globalTeardown: "./test/global-teardown.js",
testEnvironment: "@pgmem/core/jest-environment",
setupFilesAfterEnv: ["./test/reset.js"], // optional, per-test reset
};
// test/global-setup.js
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());
};
// test/global-teardown.js
module.exports = () => globalThis.pgmem.close();
// test/reset.js
const { currentFork } = require("@pgmem/core");
beforeEach(() => currentFork().reset());The environment gives each Jest worker one fork and resets it between the
test files the worker runs. It needs jest-environment-node, which Jest
installs.
node:test
node --test --test-global-setup=./test/global-setup.mjs --import @pgmem/core/register// test/global-setup.mjs (Node 24 or newer)
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();
}Each test file runs in its own process, so --import gives every file its
fork. For per-test resets add beforeEach(() => currentFork().reset()).
Bun
bun test has no global setup and shares one process by default. Start
pgmem in a small script and run the tests with --isolate:
// test/run.mjs
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);Preparing the schema
prepare receives the template server (url, host, port, ...). Close
or commit what it opens: the snapshot waits for open transactions.
- Prisma 7: run
prisma migrate deployorprisma db pushwithDATABASE_URLin the child's environment (prisma.config.tsreads it withenv("DATABASE_URL"), anddotenvdoes not override a variable that is already set).prisma migrate devworks as well: Prisma creates its shadow database on the same pgmem server.examples/prismaruns both, and has a script that authors migrations against pgmem instead of a local PostgreSQL. - Drizzle:
const db = drizzle(url); await migrate(db, { migrationsFolder: "drizzle" }); await db.$client.end(); - TypeORM:
const ds = await new DataSource({ ...options, url }).initialize(); await ds.runMigrations(); await ds.destroy();
Things to know
- A fork is a PostgreSQL cluster of its own: every connection gets its own 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).- Keep
sslmode=disablein the URL:pgtreatspreferandrequireas "TLS required", and pgmem has no TLS. - Forks made through
@pgmem/core/register,fork()orwithFork()belong to the process that made them and close when it exits. Closing a fork leaves idle pooled connections open until their pool closes them, so a pool without anerrorlistener does not crash the test process. PGMEM_BINARYpoints at a local build ofcmd/pgmemfor platforms without a binary package.
API
| | |
|---|---|
| PgmemServer.start(options) | database, user, params, prepare, maxForks, log, binary |
| server.url, server.template, server.snapshot | the prepared template and its snapshot |
| server.env() | { PGMEM_CONTROL, PGMEM_SNAPSHOT } for test processes |
| server.fork(), server.withFork(fn), server.close() | |
| useFork() | what @pgmem/core/register runs: fork (or reset) and write DATABASE_URL, or the names in PGMEM_ENV |
| currentFork() | the fork of this test file |
| withTestDatabase(fn, { fork, env }) | route a callback to a fresh fork (default) or a shared read-only fork |
| pgmemTest(test, name, fn, options) | register a test using withTestDatabase |
| startTestApp({ fork, command, args, healthPath, ... }) | launch an HTTP app with the fork URL and free port; wait for readiness; close it afterward |
| fork(), withFork(fn), connect() | fork through PGMEM_CONTROL from any process |
| fork.url, fork.env(names) | DATABASE_URL by default; PGHOST, PGPORT, PGUSER, PGDATABASE get the parts |
| fork.reset({ snapshot, timeoutMs }) | back to the snapshot in place |
| fork.snapshot() | a snapshot of this fork to reset to later |
| fork.close() | |
