コンテンツにスキップ

Node.js ユニットテスト

Node.js のテストランナーはテストファイルごとに新しいワーカーやモジュールコンテキストで読み込みます。そして ORM のクライアントは、作られたときに一度だけ DATABASE_URL を読みます。pgmem は実行全体でプロセスを 1 つ起動し、テストファイルの import 前に、そのファイル用のフォークを DATABASE_URL として渡します。同じファイル内のテストケースはそのフォークを共有します。読み取り専用テストに追加のフォークやリセットは要りません。書き込みテストだけを追加で分離します。テストケースごとに DATABASE_URL を書き換えても、作成済みクライアントの接続先は変わりません。

HTTP アプリを起動したまま試す場合は API テスト と Playwright E2E テスト を参照してください。

分離方式の選択とアプリ経由のサンプルも参照してください。

この global setup の例は Node.js 24 以降で使います。パッケージを追加し、次の 2 ファイルを保存して、末尾のコマンドを実行します。

ターミナルウィンドウ
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);
});
ターミナルウィンドウ
node --test --test-global-setup=./test/global-setup.mjs --import @pgmem/core/register test/*.test.mjs

pool はファイルごとに一度作り、書き込みテストだけリセットします。withTestReset(fn, { snapshot, timeoutMs }) で seed 後の基準データも指定できます。テスト本体と reset が両方失敗した場合は、両方のエラーを残します。同じフォークで包んだテスト本体が重なって実行されるとエラーになります。同時に書き込むテストでは withFork を使い、それぞれの URL からクライアントを作ってください。

プロジェクト内で複数サービスを組み合わせる

Section titled “プロジェクト内で複数サービスを組み合わせる”

サービスのラッパーをアプリ側の helper にまとめると、各テストでは用途を一度指定するだけで済みます。

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

各サービスが自身の接続先、準備、後片付けを受け持ちます。環境変数名は別々にしてください。pgmem 側は PGMEM_ENV または useFork({ env: [...] }) で設定できます。アプリのクライアントを import する前に、すべての接続先を設定します。global setup で組み合わせる場合は、後続サービスの起動失敗時に、先に起動したサービスも終了させます。後片付けも、一つ失敗しただけで残りを省略しない形にしてください。

Jest では既存 environment に factory で pgmem を追加できます。

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.

Jest の設定に testEnvironment: './test/environment.cjs' を指定します。factory は基底 environment の準備と後片付けを呼び、セットアップが失敗した場合も後片付けを実行します。必要なら第 2 引数に { env: ['APP_DATABASE_URL'], forkTimeoutMs: 30000, resetTimeoutMs: 5000 } を渡してください。既存の @pgmem/core/jest-environment は、Node 用 environment と組み合わせ済みのエントリです。

Node.js のテストランナーはテストファイルごとに新しいワーカーやモジュールコンテキストで読み込みます。そして ORM のクライアントは、作られたときに一度だけ DATABASE_URL を読みます。pgmem は実行全体でプロセスを 1 つ起動し、テストファイルの import 前に、そのファイル用のフォークを DATABASE_URL として渡します。同じファイル内のテストケースはそのフォークを共有します。読み取り専用テストに追加のフォークやリセットは要りません。書き込みテストだけを追加で分離します。テストケースごとに DATABASE_URL を書き換えても、クライアントはもう URL を持っているので効きません。

shadowPg で既存の pg クライアントを切り替える

Section titled “shadowPg で既存の pg クライアントを切り替える”

shadowPg(test, 名前, コールバック) は、pg の Client と Pool の新しい接続をテストごとの pgmem フォークへ向けます。アプリの接続文字列や DATABASE_URL は変更しません。テスト前に作った Pool や @prisma/adapter-pg を使う Prisma クライアントでも、次の接続から切り替わります。スキーマとシードデータは global setup で準備した snapshot からコピーします。{ fork: false } は読み取り専用テストに限って使ってください。

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(); // アプリの設定はそのまま
shadowPg(test, 'ユーザーを作成する', async () => {
const created = await users.create('ada@example.com', 'Ada');
expect(await users.findByEmail('ada@example.com')).toEqual(created);
});
shadowPg(test, '準備済みデータから始まる', async () => {
expect(await users.findByEmail('ada@example.com')).toBeNull();
});

アプリの Pool はランナー終了時に閉じてください。同じ Pool を共有するテストを並列実行しないでください。マーカー開始前に取り出した接続は移せません。この切り替えは pg とその上に構築された @prisma/adapter-pg などが対象です。ほかの PostgreSQL ドライバには別のアダプターが必要です。

テスト内で作るクライアントのケース別切り替え

Section titled “テスト内で作るクライアントのケース別切り替え”

pgmemTest はデフォルトでケースごとに新しいフォークを作り、一時的に DATABASE_URL を向けます。DB クライアントはコールバック内で作ってください。作成済みのクライアントは接続先を保持します。fork: false は共有フォークを再利用するため、読み取り専用ケースに限って使います。process.env はプロセス全体で共有されるので、ヘルパーはコールバックを逐次実行します。import 時に作るクライアントには、後述の register フックと reset() を使います。

UserRepository がプールを持ち、コンストラクターで DATABASE_URL を読むとします。テストは切り替え対象のコールバック内でリポジトリを作り、公開メソッドの振る舞いを検証します。

test/user-repository.test.ts
import { test, expect } from 'vitest';
import { pgmemTest } from '@pgmem/core';
import { UserRepository } from '../src/user-repository';
pgmemTest(test, 'ユーザーを作成して検索する', 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, '存在しないユーザーは null を返す', async () => {
const users = new UserRepository();
try {
expect(await users.findByEmail('missing@example.com')).toBeNull();
} finally {
await users.close();
}
}, { fork: false });

データベースのライフサイクル

Section titled “データベースのライフサイクル”

テストファイルごとに独自のスキーマが必要なら、新しいサーバーを使います。同じスキーマとシードデータを共有する場合は、実行ごとにスナップショットを 1 つ準備します。register フックがテストファイルごとに 1 つフォークを作り、読み取り専用ケースはそれを共有します。書き込みケースだけ、追加のリセットやフォークで分離します。同時に書き込むテストには別々のフォークが必要です。

テストファイルごとに新しいサーバー

Section titled “テストファイルごとに新しいサーバー”
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('新しいカラムを読める', async () => {
// server.url に接続する
});

ランナーのグローバルセットアップでサーバーを起動し、server.env() を環境変数に書き出します。中身は PGMEM_CONTROL と PGMEM_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 はスナップショットをフォークし、テストファイルの import が走る前にフォークの URL を DATABASE_URL に書き込みます。import 時にクライアントを作るアプリケーションのコードも、そのファイル専用の複製に接続します。このファイル単位のフォークをケース同士で共有し、追加のリセットやフォークは書き込みテストだけで使います。テストファイルは並列に、それぞれ自分のフォークで走ります。別の環境変数名に書き込みたいときは PGMEM_ENV を設定してください。

読み取り専用テストでファイル用フォークを共有する

Section titled “読み取り専用テストでファイル用フォークを共有する”

同じファイルのテストは、そのファイルのフォークを共有します。読むだけのテストしかないファイルなら、ほかに何も要りません。

import { expect, test } from 'vitest';
import { prisma } from '../src/db'; // process.env.DATABASE_URL から作られる
test('ユーザーを数える', async () => {
expect(await prisma.user.count()).toBe(0);
});

書き込みテストだけを分離する

Section titled “書き込みテストだけを分離する”

reset() はファイルのフォークをその場でスナップショットの状態に戻します。書き込みテストをまとめたスイートの afterEach から呼ぶと、そのケースの後でだけ基準状態に戻せます。読み取り専用テストはリセット不要です。URL とプール接続はそのまま使え、プリペアドステートメントも残ります。

test/orders.test.ts
import { afterEach, describe, test } from 'vitest';
import { currentFork } from '@pgmem/core';
describe('書き込みテスト', () => {
afterEach(() => currentFork().reset());
test('準備済みデータベースから始まる', async () => {
// データベースに書き込む
});
});

分離しないと、この 2 つのテストは実行順に左右されます。signUp が先に走ると、2 つ目は失敗します。前の書き込み専用スイートでは、書き込みケースの後に共有フォークをリセットします。

test/users.test.ts
import { expect, test } from 'vitest';
import { prisma } from '../src/db';
import { signUp } from '../src/blog';
test('サインアップ', async () => {
await signUp('alice@example.com');
expect(await prisma.user.count()).toBe(1);
});
test('次のテストは空のデータベースから始まる', async () => {
expect(await prisma.user.count()).toBe(0);
});

書き込みスイート専用のシード状態を作る。 シード後のスナップショットを取り、実行全体の基準状態ではなく、書き込みケースの後にそこへリセットします。

import { afterEach, beforeAll, describe, test } from 'vitest';
import { currentFork, type PgmemSnapshot } from '@pgmem/core';
describe('書き込みテスト', () => {
let seeded: PgmemSnapshot;
beforeAll(async () => {
await seedOrders(process.env.DATABASE_URL!);
seeded = await currentFork().snapshot();
});
afterEach(() => currentFork().reset({ snapshot: seeded }));
test('このスイートのシードを使う', async () => {
// データベースに書き込む
});
});

同時に走るテストは 1 つのフォークを共有できません。withFork でそれぞれに専用のフォークを渡し、URL を受け取れるコードに渡します。

import { withFork } from '@pgmem/core';
import { test } from 'vitest';
test.concurrent('ファイルを取り込む', async () => {
await withFork(async (fork) => {
const app = createApp({ databaseUrl: fork.url });
await app.importCsv('fixtures/orders.csv');
});
});

読み取り専用ファイルでテンプレートを共有する

Section titled “読み取り専用ファイルでテンプレートを共有する”

多くのスイートでは、ファイルの半分以上が読むだけです。そうしたファイルにはフォークそのものが要りません。テンプレートサーバーに直接向け、@pgmem/core/register は書き込むファイルにだけ使います。読み取り専用ファイルはフォーク枠を消費しなくなり、プールは書き込みファイルだけが使います。

テンプレートもフォークと同じ PostgreSQL クラスタです。どのファイルのどの接続にもそれぞれバックエンドプロセスが付くので、読み取り専用ファイルはサーバーに対するときと同じように並列に実行できます。フォークが必要なのは書き込むファイルだけです。

Vitest ではファイルを 2 つの project に分けます。グローバルセットアップで server.env() に加えてテンプレートの URL も export します。

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 でも projects の testMatch と setupFiles で同じ分け方ができます。node:test では 2 つのディレクトリを別コマンドで実行し、書き込み側にだけ --import @pgmem/core/register を渡してください。

PgmemServer.start() の maxForks は既定のスナップショットから作るフォーク数を制限します。既定値はメモリから決まり、サーバープロセスのメモリ上限の 4 分の 1 をフォーク 1 個のコスト(shared_buffers + 約 32MB)で割った値で、7GB の CI ランナーなら 28 です。@pgmem/core/register は各テストファイルの import 前に 1 つフォークするため、すべてのファイルが枠を使います。プールが満杯なら、ほかのファイルのフォークが閉じるまで register が待ちます。自動取得の既定の期限は 30 秒で、forkTimeoutMs または PGMEM_FORK_TIMEOUT_MS で変更できます。再利用する worker の reset は別の既定値 5 秒で、resetTimeoutMs または PGMEM_RESET_TIMEOUT_MS を使います。

明示的な fork() と withFork() には timeoutMs を指定できます。期限までに枠が空かなければ、PgmemError の code pool_timeout で失敗します。

const pg = await PgmemServer.start({ maxForks: 4 });
await pg.withFork(async (fork) => {
await runImport(fork.url);
}, { timeoutMs: 30_000 });

フォークごとにデータディレクトリとバッファキャッシュの複製が増えます。フォーク枠、スナップショット、リセット、接続待ちのタイムアウトの違いは制限事項を参照してください。

  • フォーク 1 つは独立した PostgreSQL クラスタで、接続ごとにバックエンドプロセスが起動します。プール、接続間のロック、デッドロック検出はサーバーと同じように動きます。
  • reset は開いているトランザクションを待ち、timeoutMs(既定 5 秒)を過ぎると busy で失敗します。
  • @pgmem/core/register、fork()、withFork() で作ったフォークは作ったプロセスのもので、そのプロセスが終わると閉じます。

別ファイルのテストはランナーの別ワーカーで実行します。@pgmem/core/register がファイルごとにフォークを用意します。同じファイルで 1 つのフォークやアプリの Pool を共有する書き込みケースは逐次実行してください。shadowPg は AsyncLocalStorage に接続先を保持しますが、共有 Pool から取り出し中の 1 接続を同時に 2 つのフォークへ向けることはできません。ケースを並列化するなら別の Pool を作るか、別ワーカーファイルに分けます。pgmemTest は process.env を変更し、コールバックを逐次実行します。共有アプリ状態の並列化には使いません。他のケースが利用中のフォークを reset せず、ケース終了前に DB 作業を完了させてください。

サービスごとに環境変数の所有を宣言する

Section titled “サービスごとに環境変数の所有を宣言する”

@pgmem/core/environment は test runner に依存しない小さな helper で、各サービスが使えます。別サービスが同じ変数を取得しようとすると、値を変更する前に ServiceEnvConflict になります。直接代入するサービスは所有を識別できません。Jest の realm 内では同じ target と scope を渡してください。解放は元の値を戻し、繰り返し呼んでも安全です。

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、PGMEM_READ_ONLY=true で、注入するファイル用 URL に永続データへの通常の書き込みを防ぐガードを付けます。クライアントを import する前に設定してください。書き込みケースには別の書き込み可能なフォークを使います。fork.env(names, { readOnly: true }) は PGHOST/PGPORT などに分割する場合、PGOPTIONS も返します。