Prisma + PGlite(in-memory) でリアルDBを使ったテストの並列実行
データベースをモック化するより、リアルなデータベースを使ったテストを行いたい。
ただし、データベースを使うテストは、データの整合性を保つために基本は直列で実行する必要があり、そしてテストケースが増えると実行時間がどんどん長くなって開発効率が悪くなる。
今回は PGlite を使って Prisma のテストを並列実行する方法を試した。ローカル開発・本番環境では Docker の PostgreSQL を使いつつ、テストでは PGlite の in-memory データベースを使うというアプローチ。
従来のテスト実行の問題
直列実行の制約
既存のテストでは実データベース(PostgreSQL)を使用していたため、以下のような制約があった:
- データベース競合: 複数のテストが同じデータベースインスタンスを共有するため、並列実行時にデータの競合が発生する
- テスト間の依存: 前のテストのデータが次のテストに影響する可能性がある
- 実行速度: データベースへの実際の I/O 操作により、テスト実行時間が長くなる
// 従来のテスト実行(直列)
describe("User tests", () => {
beforeEach(async () => {
// データベースのクリーンアップが必要
await prisma.user.deleteMany();
});
test("should create user", async () => {
// テスト1
});
test("should update user", async () => {
// テスト2(テスト1の完了を待つ必要がある)
});
});
vitest
はじめとするテストツールではテストファイル別に並列実行されるが、それらは 競合を回避するため強制的に直列実行する設定へ。
// vitest.config.ts
export default defineConfig({
test: {
fileParallelism: false
}
});
解決策の検討
テスト実行速度を改善するため、以下の選択肢を検討した:
Testcontainers
- メリット: 実際の PostgreSQL コンテナを使うため、本番環境との互換性が高い
- デメリット: コンテナの起動・停止オーバーヘッドが大きく、並列実行時のリソース消費が多い
PGlite
- メリット: WebAssembly ベースの in-memory データベースで高速、並列実行に最適
- デメリット: PostgreSQL の一部機能に制限がある可能性
今回は PGlite を選択。
PGlite を使うメリット
高速な実行速度
- In-memory 実行: ディスク I/O が不要で、メモリ上でのデータベース操作ができる
- 軽量: WebAssembly ベースで起動が高速
- 並列実行: テストファイルごとに独立したデータベースインスタンスを作成可能
PostgreSQL 互換性
- SQL 互換: PostgreSQL と同じ SQL 文法をサポートしている
- Prisma 対応: Prisma のドライバーアダプターを使って簡単に統合できる
テスト分離
- 完全分離: 各テストが独立したデータベースインスタンスを持つ
- 副作用なし: テスト間でのデータ競合や依存関係が発生しない
実装方法
1. 依存関係の追加
bun add @electric-sql/pglite @prisma/adapter-pglite
2. テスト用データベース作成関数
import { PGlite } from "@electric-sql/pglite";
import { PrismaPGlite } from "@prisma/adapter-pglite";
import { PrismaClient } from "@prisma/client";
export async function createTestDatabase(): Promise<PrismaClient> {
const pglite = new PGlite();
const adapter = new PrismaPGlite(pglite);
const prisma = new PrismaClient({
adapter,
log: ["error"],
});
await applyMigrations(pglite);
return prisma;
}
なお、adapter
を利用する場合は、schema.prisma
に previewFeatures = ["driverAdapters"]
を追加すること
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
age Int?
}
3. 独特なマイグレーション適用方法
Prisma にはmigrate
API が存在しないため、手動でマイグレーションファイルを読み込んで実行する必要がある。
import fs from "fs";
import path from "path";
export async function applyMigrations(pglite: PGlite): Promise<void> {
const migrationsDir = path.join(process.cwd(), "prisma", "migrations");
if (!fs.existsSync(migrationsDir)) {
console.log("マイグレーションディレクトリが見つかりません");
return;
}
// マイグレーションフォルダを取得してソート
const migrationFolders = fs
.readdirSync(migrationsDir)
.filter((folder) =>
fs.statSync(path.join(migrationsDir, folder)).isDirectory()
)
.sort(); // タイムスタンプ順にソート
for (const folder of migrationFolders) {
const migrationFile = path.join(migrationsDir, folder, "migration.sql");
if (fs.existsSync(migrationFile)) {
const migrationSQL = fs.readFileSync(migrationFile, "utf-8");
console.log(`マイグレーション適用中: ${folder}`);
await pglite.exec(migrationSQL);
console.log(`マイグレーション完了: ${folder}`);
}
}
}
ちなみにexecFileSync
などで別途 npm run~
を実行すると別プロセスで実行されるため、当該の PGlite インスタンスにうまく migration が割り当てされず断念した。これらの課題は drizzle では migrate API が存在するため、programmatic な migration が可能。 PGlite を使うなら drizzle に一日の長がある。
4. テストでの使用方法
import { createTestDatabase } from "../test-utils/create-test-database";
describe("User operations", () => {
let prisma: PrismaClient;
beforeEach(async () => {
// 各テストで新しいデータベースインスタンスを作成
prisma = await createTestDatabase();
});
afterEach(async () => {
// リソースの適切な解放
await prisma.$disconnect();
});
test("should create user", async () => {
const user = await prisma.user.create({
data: { name: "Test User", email: "test@example.com" },
});
expect(user.name).toBe("Test User");
});
test("should update user", async () => {
// 独立したデータベースなので並列実行可能
const user = await prisma.user.create({
data: { name: "Original", email: "original@example.com" },
});
const updated = await prisma.user.update({
where: { id: user.id },
data: { name: "Updated" },
});
expect(updated.name).toBe("Updated");
});
});
パフォーマンス比較
手元の250テストで計測、完了までの時間はおおよそ1/5以下程度に。
Before:
Test Files 32 passed (32)
Tests 250 passed | 1 skipped (251)
Start at 06:46:03
Duration 51.02s (transform 626ms, setup 2.06s, collect 11.35s, tests 28.70s, environment 5.45s, prepare 1.11s)
After:
Test Files 32 passed (32)
Tests 250 passed | 1 skipped (251)
Start at 02:11:57
Duration 10.27s (transform 1.62s, setup 1.32s, collect 15.90s, tests 30.08s, environment 6.20s, prepare 1.41s)
補足: prismaをグローバルインスタンス化してる場合の処置
Next.jsを利用している場合、HMR対策でローカル開発ではprismaをグローバルインスタンス化することが推奨されている。
この通りに実装するとテストファイルごとにインスタンスを持ちたくともグローバル化されてしまう。従って次の対処をすれば良い。
// lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma || new PrismaClient();
// NODE_ENV = testでglobalインスタンスを使わない
if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test") globalForPrisma.prisma = prisma;
// xxx.test.ts
let prisma: PrismaClient;
beforeEach(async () => {
prisma = await createTestDatabase();
});
afterEach(async () => {
await prisma.$disconnect();
});
// lib/prisma.tsで生成されるインスタンスをテストインスタンスに差し替える
vi.mock("@/lib/prisma", () => {
return {
get prisma() {
return prisma;
},
};
});
あとはテスト実行時に NODE_ENV=test
で動かすと良い。
まとめ
PGlite を使うことで、以下のメリットが得られた:
- 実行速度の大幅向上: in-memory データベースによる高速実行
- 真の並列実行: テストファイル間での完全な分離
- 開発効率の向上: 高速なフィードバックループ
- PostgreSQL 互換性: 本番環境との一貫性を保持
独特なマイグレーション適用方法は必要だが、テスト実行速度の向上という大きなメリットがある。特に大規模なプロジェクトでテスト実行時間に悩んでいる場合は、PGlite の導入を検討する価値があると思う。
Discussion