Skip to content

Node.js API tests

An API test sends HTTP requests to an application created in the test process. Its database pool may be constructed when the app module is imported. Give that process a fork before importing the app, then reset the same fork between test cases. The URL and open connections remain valid across a reset.

API tests without changing the app datasource

Section titled “API tests without changing the app datasource”

For in-process Express + Supertest or Fastify inject, the execution context flows from the test callback through the request to pg. Prepare the snapshot in global setup, then use the app’s unchanged datasource. Its Pool may be constructed before the test.

test/api-shadow.test.ts
import request from 'supertest';
import { afterAll, expect, test } from 'vitest';
import { shadowPg } from '@pgmem/core';
import { app } from '../src/app';
import { pool } from '../src/db';
afterAll(() => pool.end());
shadowPg(test, 'creates an order', async () => {
const response = await request(app).post('/orders').send({ sku: 'book' });
expect(response.status).toBe(201);
});
shadowPg(test, 'starts from prepared data', async () => {
const response = await request(app).get('/orders');
expect(response.body).toHaveLength(0);
});

For Fastify, replace request(app) with app.inject(...). Run cases sequentially and close the app Pool at teardown. This test process’s execution context does not cross into a separately running HTTP server.

Prepare the database and register the fork

Section titled “Prepare the database and register the fork”

Use the unit test global setup to run migrations and seed once. For Vitest, keep @pgmem/core/register first in setupFiles so it sets DATABASE_URL before the app is imported. The same setup works for Express and Fastify.

vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globalSetup: ['./test/global-setup.ts'],
setupFiles: ['@pgmem/core/register'],
},
});

Create the app once per test file. beforeEach restores the prepared snapshot before every case, including a case that follows a failed assertion. Close the app and its database pool in afterAll. These examples assume src/app exports an app and src/db exports its pool; adapt those imports to your project.

test/api.test.ts
import request from 'supertest';
import { afterAll, beforeEach, expect, test } from 'vitest';
import { currentFork } from '@pgmem/core';
import { app } from '../src/app';
import { pool } from '../src/db';
beforeEach(() => currentFork().reset());
afterAll(() => pool.end());
test('creates an order through HTTP', async () => {
const created = await request(app).post('/orders').send({ sku: 'book' });
expect(created.status).toBe(201);
const list = await request(app).get('/orders');
expect(list.body).toHaveLength(1);
});
test('the next request sees the prepared seed', async () => {
const list = await request(app).get('/orders');
expect(list.body).toHaveLength(0);
});

Seed the file fork once, snapshot it, and restore that snapshot before each case. Keep the snapshot alive until the suite finishes.

import { beforeAll, beforeEach, afterAll } from 'vitest';
import { currentFork, type PgmemSnapshot } from '@pgmem/core';
let seeded: PgmemSnapshot;
beforeAll(async () => {
await seedOrders(process.env.DATABASE_URL!);
seeded = await currentFork().snapshot();
});
beforeEach(() => currentFork().reset({ snapshot: seeded }));
afterAll(() => seeded.close());

For a browser and a separately running app process, use E2E tests.

Use runner workers for file-level parallelism: each worker needs its own app instance and pgmem fork. Cases that share one Express or Fastify app and Pool must run sequentially, even when wrapped in shadowPg. Its asynchronous context routes new pg connections, while a checked-out connection still belongs to its current fork. test.concurrent is unsafe with the shared app/reset examples above. To parallelize individual cases, give each case a separate app and Pool and a fresh fork; otherwise split the cases into files handled by separate workers. Wait for request handlers, background jobs, and transactions to finish before resetting or closing the target.