Vitest、Jest、node:test、Bun への組み込み
Node.js のテストランナーはテストファイルごとに新しいワーカーやモジュールコンテキストで読み込みます。そして ORM のクライアントは、作られたときに一度だけ DATABASE_URL を読みます。pgmem は実行全体でプロセスを 1 つ起動し、テストファイルの import 前に、そのファイル用のフォークを DATABASE_URL として渡します。同じファイル内のテストケースはそのフォークを共有します。読み取り専用テストに追加のフォークやリセットは要りません。書き込みテストだけを追加で分離します。テストケースごとに DATABASE_URL を書き換えても、クライアントはもう URL を持っているので効きません。
データベースのライフサイクル
Section titled “データベースのライフサイクル”テストファイルごとに独自のスキーマが必要なら、新しいサーバーを使います。同じスキーマとシードデータを共有する場合は、実行ごとにスナップショットを 1 つ準備します。register フックがテストファイルごとに 1 つフォークを作り、読み取り専用ケースはそれを共有します。書き込みケースだけ、追加のリセットやフォークで分離します。同時に書き込むテストには別々のフォークが必要です。
テストファイルごとに新しいサーバー
Section titled “テストファイルごとに新しいサーバー”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 に接続する});実行全体で一度だけ準備する
Section titled “実行全体で一度だけ準備する”ランナーのグローバルセットアップでサーバーを起動し、server.env() を環境変数に書き出します。中身は PGMEM_CONTROL と PGMEM_SNAPSHOT で、テストプロセスはこれを使ってサーバーとスナップショットにたどり着きます。
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();}import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globalSetup: ['./test/global-setup.ts'], setupFiles: ['@pgmem/core/register'], },});module.exports = { globalSetup: './test/global-setup.js', globalTeardown: './test/global-teardown.js', testEnvironment: '@pgmem/core/jest-environment',};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());};module.exports = () => globalThis.pgmem.close();この環境は Jest のワーカーごとにフォークを 1 つ用意し、そのワーカーが実行するテストファイルの合間にリセットします。Jest がインストールする jest-environment-node が必要です。
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();}node --test --test-global-setup=./test/global-setup.mjs --import @pgmem/core/registerグローバルセットアップには Node.js 24 以降が必要です。テストファイルはそれぞれ別プロセスで動くので、--import で各ファイルにフォークが渡ります。
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);bun test にはグローバルセットアップがなく、既定では 1 つのプロセスを共有します。そこで小さなスクリプトで pgmem を起動し、--isolate を付けてテストを走らせます。
@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 とプール接続はそのまま使え、プリペアドステートメントも残ります。
import { afterEach, describe, test } from 'vitest';import { currentFork } from '@pgmem/core';
describe('書き込みテスト', () => { afterEach(() => currentFork().reset()); test('準備済みデータベースから始まる', async () => { // データベースに書き込む });});const { currentFork } = require('@pgmem/core');
describe('書き込みテスト', () => { afterEach(() => currentFork().reset()); test('準備済みデータベースから始まる', async () => { // データベースに書き込む });});import { afterEach, describe, it } from 'node:test';import { currentFork } from '@pgmem/core';
describe('書き込みテスト', () => { afterEach(() => currentFork().reset()); it('準備済みデータベースから始まる', async () => { // データベースに書き込む });});分離しないと、この 2 つのテストは実行順に左右されます。signUp が先に走ると、2 つ目は失敗します。前の書き込み専用スイートでは、書き込みケースの後に共有フォークをリセットします。
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 “フォーク数と待機”PgmemServer.start() の maxForks は既定のスナップショットから作るフォーク数を制限します。既定値は利用可能な CPU 数です。@pgmem/core/register は各テストファイルの import 前に 1 つフォークするため、すべてのファイルが枠を使います。プールが満杯なら、ほかのファイルのフォークが閉じるまで register が待ちます。この自動待機に期限はありません。
明示的な 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 });フォークごとにデータディレクトリとバッファキャッシュの複製が増えます。フォーク枠、スナップショット、リセット、接続待ちのタイムアウトの違いは制限事項を参照してください。
知っておきたいこと
Section titled “知っておきたいこと”- フォーク 1 つは PostgreSQL の 1 セッションで、トランザクションモードのプーラーと同じように全接続で共有します。プールは使えますが、
SET、一時テーブル、アドバイザリーロックは共有されるのでSET LOCALを使ってください。 - トランザクションのコールバックの中ではトランザクションのハンドルでクエリを投げてください。 別のプール接続で投げると、そのトランザクションが終わるまで待つことになります。pgmem はその待ちを
waitTimeoutMs(既定 2 秒)後に SQLSTATE 55P03 で打ち切り、両方の接続を示すメッセージを返します。 resetは開いているトランザクションを待ち、timeoutMs(既定 5 秒)を過ぎるとbusyで失敗します。@pgmem/core/register、fork()、withFork()で作ったフォークは作ったプロセスのもので、そのプロセスが終わると閉じます。