Practice: test-db-isolation
出典: epicweb-dev/epic-stack カテゴリ: practice
概要
SQLite ファイルコピーと Vitest の VITEST_POOL_ID を組み合わせた並列テスト DB 分離パターン。global-setup で base DB を1回だけ生成し、各テストの beforeEach でワーカー固有のファイルにコピーすることで、トランザクションロールバックなしにテスト間の完全な分離を実現する。ローカル開発では mtime 比較で再生成をスキップし、CI ではスキーマ+マイグレーションのハッシュでキャッシュする二段構えの最適化も備える。
背景・文脈
テストで DB を使う場合、テスト間のデータ汚染は最も厄介な問題の一つである。一般的な解決策はトランザクションロールバック(テストをトランザクションで囲み、終了時にロールバック)だが、並列実行時の競合や、トランザクション内では再現できない振る舞い(DDL、COMMIT 後のイベント等)に対処できない。
Epic Stack は SQLite のファイルベース特性を活かし、「マイグレーション済みの base DB ファイルをテストごとにコピーする」というアプローチを採用している。Vitest のワーカープールごとに独立した DB ファイルを持ち、beforeEach でコピーし、afterAll で削除する。この Copy-on-Test パターンにより、並列実行時のデータ競合を物理ファイルレベルで排除している。
実装パターン
全体は3層構造で設計されている。
第1層: global-setup -- base DB の生成(1回だけ)
テストスイート全体の開始前に、Prisma マイグレーションでスキーマだけの base DB を作成する。--skip-seed でシードデータを入れないのがポイント。テスト固有のデータはテスト自身が生成する。
// tests/setup/global-setup.ts:1-39
import { execaCommand } from "execa";
import fsExtra from "fs-extra";
import path from "node:path";
import "dotenv/config";
import "#app/utils/env.server.ts";
export const BASE_DATABASE_PATH = path.join(
process.cwd(),
`./tests/prisma/base.db`,
);
export async function setup() {
const databaseExists = await fsExtra.pathExists(BASE_DATABASE_PATH);
if (databaseExists) {
const databaseLastModifiedAt = (await fsExtra.stat(BASE_DATABASE_PATH))
.mtime;
const prismaSchemaLastModifiedAt = (
await fsExtra.stat("./prisma/schema.prisma")
).mtime;
if (prismaSchemaLastModifiedAt < databaseLastModifiedAt) {
return;
}
}
await execaCommand(
"npx prisma migrate reset --force --skip-seed --skip-generate",
{
stdio: "inherit",
env: {
...process.env,
DATABASE_URL: `file:${BASE_DATABASE_PATH}`,
},
},
);
}mtime 比較により、schema.prisma が変更されていなければマイグレーションをスキップする。ローカル開発でテストを繰り返し実行する際の起動時間を大幅に短縮する仕組みである。
第2層: db-setup -- ワーカーごとの DB 分離
各 Vitest ワーカーのセットアップ時に実行される。VITEST_POOL_ID を DB ファイル名に埋め込み、ワーカー間の衝突を防ぐ。
// tests/setup/db-setup.ts:1-31
import fsExtra from "fs-extra";
import path from "node:path";
import { afterAll, beforeEach } from "vitest";
import { BASE_DATABASE_PATH } from "./global-setup.ts";
const poolId = process.env.VITEST_POOL_ID || "0";
const databaseFile = `./tests/prisma/data.${poolId}.db`;
const databasePath = path.join(process.cwd(), databaseFile);
process.env.DATABASE_URL = `file:${databasePath}`;
const cacheDatabasePath = process.env.CACHE_DATABASE_PATH;
if (cacheDatabasePath && cacheDatabasePath !== ":memory:") {
const parsed = path.parse(cacheDatabasePath);
const cacheFileName = parsed.ext
? `${parsed.name}.${poolId}${parsed.ext}`
: `${parsed.name}.${poolId}`;
const cacheDir = parsed.dir || ".";
process.env.CACHE_DATABASE_PATH = path.join(cacheDir, cacheFileName);
}
beforeEach(async () => {
await fsExtra.copyFile(BASE_DATABASE_PATH, databasePath);
});
afterAll(async () => {
// we *must* use dynamic imports here so the process.env.DATABASE_URL is set
// before prisma is imported and initialized
const { prisma } = await import("#app/utils/db.server.ts");
await prisma.$disconnect();
await fsExtra.remove(databasePath);
});注目すべき設計判断が3つある:
beforeEachでコピー: テスト「ごと」に真っ白な DB を提供する。テスト A が書き込んだデータがテスト B に漏れない- キャッシュ DB も同様に分離:
CACHE_DATABASE_PATHにもpoolIdを埋め込み、複数の永続化レイヤーで一貫した分離を適用 afterAllで動的インポート: Prisma クライアントは初回インポート時にDATABASE_URLを読む。静的インポートだと環境変数設定前にモジュールが初期化されてしまうため、await import()で遅延させる
第3層: Vitest 設定での統合
// vite.config.ts:74-83
test: {
include: ['./app/**/*.test.{ts,tsx}'],
setupFiles: ['./tests/setup/setup-test-env.ts'],
globalSetup: ['./tests/setup/global-setup.ts'],
restoreMocks: true,
},globalSetup はテストスイート全体で1回、setupFiles は各テストファイルの実行前に毎回実行される。setup-test-env.ts はインポート順で初期化を制御する:
// tests/setup/setup-test-env.ts:1-4
import "dotenv/config";
import "./db-setup.ts";
import "#app/utils/env.server.ts";
// we need these to be imported firstCI でのキャッシュ戦略
CI 環境では GitHub Actions のキャッシュ機構を使い、スキーマとマイグレーションのファイルハッシュをキーにする:
# .github/workflows/deploy.yml:118-130
- name: Cache Database
id: db-cache
uses: actions/cache@v4
with:
path: prisma/data.db
key:
db-cache-schema_${{ hashFiles('./prisma/schema.prisma')
}}-migrations_${{ hashFiles('./prisma/migrations/*/migration.sql')
}}
- name: Seed Database
if: steps.db-cache.outputs.cache-hit != 'true'
run: npx prisma migrate reset --forceスキーマもマイグレーションも変更されていなければキャッシュがヒットし、数秒~数十秒のシード処理がスキップされる。
Good Example
base DB のコピーで各テストが独立した DB を持つ
// tests/setup/db-setup.ts:6-9, 21-23
const poolId = process.env.VITEST_POOL_ID || "0";
const databaseFile = `./tests/prisma/data.${poolId}.db`;
const databasePath = path.join(process.cwd(), databaseFile);
process.env.DATABASE_URL = `file:${databasePath}`;
beforeEach(async () => {
await fsExtra.copyFile(BASE_DATABASE_PATH, databasePath);
});- ワーカー間のファイル衝突がない(
poolIdで名前空間を分離) - テスト間のデータ汚染がない(
beforeEachで毎回上書き) - base DB はスキーマのみ(
--skip-seed)で、テストがシードデータに依存しない - ファイルコピーはナノ秒単位で完了し、トランザクションロールバックより高速
mtime 比較によるスキップ最適化
// tests/setup/global-setup.ts:15-25
if (databaseExists) {
const databaseLastModifiedAt = (await fsExtra.stat(BASE_DATABASE_PATH))
.mtime;
const prismaSchemaLastModifiedAt = (
await fsExtra.stat("./prisma/schema.prisma")
).mtime;
if (prismaSchemaLastModifiedAt < databaseLastModifiedAt) {
return; // スキーマ未変更なら再生成不要
}
}ローカル開発で「コード修正 -> テスト実行」を繰り返す際、スキーマに変更がなければマイグレーションコマンドの実行を丸ごとスキップする。
動的インポートによる環境変数の遅延バインド
// tests/setup/db-setup.ts:25-31
afterAll(async () => {
// we *must* use dynamic imports here so the process.env.DATABASE_URL is set
// before prisma is imported and initialized
const { prisma } = await import("#app/utils/db.server.ts");
await prisma.$disconnect();
await fsExtra.remove(databasePath);
});Prisma クライアントは初回ロード時に DATABASE_URL を読み取る。afterAll のクリーンアップでは、ワーカー固有の DATABASE_URL が設定済みの状態でインポートする必要がある。
Bad Example
共有 DB + トランザクションロールバック
// Bad: 全ワーカーが同じ DB を共有し、テストごとにロールバック
const prisma = new PrismaClient();
beforeEach(async () => {
await prisma.$executeRaw`BEGIN`;
});
afterEach(async () => {
await prisma.$executeRaw`ROLLBACK`;
});問題点:
- 並列実行時に複数ワーカーが同じ DB にアクセスし、ロック競合が発生する
- トランザクション内では
PRAGMAや DDL が期待通り動かない場合がある - テストが暗黙的にトランザクション内で動いており、本番環境との振る舞いの差異が生まれる
テーブル全削除によるリセット
// Bad: テストごとにテーブル内容を全削除
beforeEach(async () => {
await prisma.session.deleteMany();
await prisma.connection.deleteMany();
await prisma.note.deleteMany();
await prisma.user.deleteMany();
// テーブルが増えるたびに追加が必要...
});問題点:
- テーブル追加時にクリーンアップコードも更新が必要(ORM の内部テーブルを考慮する必要もある)
- 外部キー制約による削除順序の管理が煩雑
- ファイルコピーより遅い(複数テーブルへの DELETE 文 vs 単一ファイルコピー)
シードデータに依存するテスト
// Bad: シードデータの特定ユーザーに依存
test("kody can edit notes", async () => {
const kody = await prisma.user.findUnique({ where: { username: "kody" } });
// kody が存在する前提 → シード変更で壊れる
});
// Good: テスト自身がデータを生成
test("users can edit their own notes", async () => {
const user = createUser();
await prisma.user.create({ data: { ...user, roles: { connect: { name: "user" } } } });
// テストに必要なデータは自分で作る
});Epic Stack は --skip-seed で base DB を作成し、テストのシードデータ依存を構造的に排除している。
適用ガイド
どのような状況で使うべきか
- SQLite をテスト DB として使うプロジェクト: ファイルコピーによる分離はファイルベース DB でのみ有効。PostgreSQL/MySQL の場合は
CREATE DATABASE ... TEMPLATEやコンテナ分離を検討する - Vitest で並列テスト実行するプロジェクト:
VITEST_POOL_IDは Vitest 固有だが、Jest の--shardや他のテストランナーでも同様のワーカー ID 機構があれば応用可能 - テスト間のデータ汚染に悩んでいるプロジェクト: トランザクションロールバックや
deleteManyで不安定なテストが発生している場合の代替手段
導入時の注意点
- セットアップの実行順序:
setup-test-env.tsのインポート順は暗黙的な依存関係を持つ。dotenv/config->db-setup.ts->env.server.tsの順序が崩れると、環境変数未設定のまま後続処理が走る - 動的インポートの必要性:
afterAllで Prisma をクリーンアップする際はawait import()を使う。静的インポートだとDATABASE_URLが設定される前にモジュールが初期化されてしまう - キャッシュ DB の分離忘れ: メインの DB だけでなく、キャッシュ用 DB など複数の永続化レイヤーがある場合は全てに
poolIdを適用する必要がある
カスタマイズポイント
- mtime 比較の対象拡張:
schema.prismaだけでなく、マイグレーションファイルも mtime 比較に含めると、マイグレーション追加時にもスキップが効く。ただし CI ではファイルハッシュベースのキャッシュの方が確実 - base DB へのシード投入: Epic Stack は
--skip-seedだが、全テストで共通の初期データ(ロール定義、マスタデータ等)が必要な場合は base DB にシードを含めてもよい。ただしテストがシードデータの特定レコードに依存しないよう注意 - PostgreSQL への応用: ファイルコピーは使えないが、
CREATE DATABASE test_${poolId} TEMPLATE base_dbで類似の戦略が実現可能。テンプレート DB の作成コストはマイグレーション1回分で済む
参考
- repos/epicweb-dev/epic-stack/test-infrastructure.md -- テスト基盤の全体設計
- repos/epicweb-dev/epic-stack/test-data-management.md -- ファクトリ関数とデータ生成
- repos/epicweb-dev/epic-stack/testing-strategy.md -- テスト戦略全体像
- repos/epicweb-dev/epic-stack/ci-cd.md -- CI でのキャッシュ戦略