Node.js E2E tests with Playwright
A browser test talks to an HTTP server in another process. Changing the test process’s DATABASE_URL cannot move that server’s existing database pool. Give each Playwright worker its own pgmem server, fork, and application process. Pass the fork URL when spawning the app, then reset the same fork before each test. The app and its database connections stay alive throughout the worker.
Worker-scoped app, test-scoped reset
Section titled “Worker-scoped app, test-scoped reset”The fixture below starts pgmem and the app once per worker. prepare runs the project’s migrations and baseline seed before the fork is made. The automatic resetDb fixture restores that baseline before every browser test. appUrl is a worker fixture, so page tests in that worker reach the same app process.
import { test as base, expect } from '@playwright/test';import { PgmemServer, startTestApp } from '@pgmem/core';import { migrateAndSeed } from './prepare-db.js';
export const test = base.extend({ pgmem: [async ({}, use) => { const server = await PgmemServer.start({ database: 'app', prepare: ({ url }) => migrateAndSeed(url), }); try { const fork = await server.fork(); try { await use(fork); } finally { await fork.close(); } } finally { await server.close(); } }, { scope: 'worker' }],
appUrl: [async ({ pgmem }, use) => { const app = await startTestApp({ fork: pgmem, args: ['./src/server.js'], healthPath: '/health', }); try { await use(app.url); } finally { await app.close(); } }, { scope: 'worker' }],
resetDb: [async ({ pgmem, appUrl }, use) => { await pgmem.reset(); await use(); }, { auto: true }],});
export { expect };startTestApp finds an available loopback port, passes PORT and the fork’s DATABASE_URL to the child, waits for GET /health, and shuts the child down afterward. You can override its command, portEnv, databaseEnv, healthPath, and startup timeoutMs. src/server.js must listen on PORT, use DATABASE_URL when constructing its pool, and respond to GET /health once ready. migrateAndSeed(url) must close every connection it opens before prepare returns.
import { test, expect } from './fixtures.js';
test('creates an order', async ({ page, appUrl }) => { await page.goto(`${appUrl}/orders`); await page.getByRole('button', { name: 'New order' }).click(); await expect(page.getByText('Order created')).toBeVisible();});
test('starts from the prepared database again', async ({ page, appUrl }) => { await page.goto(`${appUrl}/orders`); await expect(page.getByText('No orders')).toBeVisible();});To use a different dataset, seed the worker fork, call fork.snapshot(), and reset with fork.reset({ snapshot: seeded }). Keep the seeded snapshot open until the worker finishes. For in-process HTTP tests, use API tests.
Safe parallel execution
Section titled “Safe parallel execution”Set a bounded Playwright worker count, for example workers: 4, and keep the worker-scoped pgmem, fork, and appUrl fixtures together. Each worker runs its own app process on a distinct port and resets only its own fork. Playwright’s parallel mode can place tests from one file in different worker processes; that remains isolated when every worker creates its own fixtures. A shared external app URL or database fork breaks that guarantee. Within a worker, wait for HTTP requests, background jobs, and open transactions before the next test resets the fork. Choose the worker count with the memory cost of pgmem and the app in mind.