Prisma × Vitestのテスト環境構築
Prisma ORMを用いたアプリケーションのテスト環境を構築しました。DBを含むテストでは、実行時間などいくつかの留意すべきことがあります。この記事ではそういったポイントを抑えつつ、一連の流れを備忘録としてまとめようと思います。
技術スタック
以下の技術スタックで環境を構築しました。
- Prisma ORM
- Vitest
- PostgreSQL
- GitHub Actions
プロジェクトの作成
はじめに、検証で使用するプロジェクトのベース部分を作成します。
Create Honoでプロジェクトを作成
> create-hono prisma-testing
create-hono version 0.15.3
✔ Using target directory … prisma-testing
? Which template do you want to use? nodejs
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm
✔ Cloning the template
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd prisma-testing
ローカルDBのセットアップ
DockerでローカルDB (PostgreSQL) が動く環境をつくります。セットアップ手順はこちらの記事をご確認ください。
Prismaのセットアップ
Prismaをインストールして、初期化コマンドを実行します。
npm install prisma --save-dev
npx prisma init
生成された schema.prisma
から参照するデータベースURLを設定します。
# 環境変数を設定
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dvd_rental
初期化コマンドではプロジェクトのルートに prisma
ディレクトリが生成されるため、管理しやすいディレクトリに移動します。そのためには、package.json
に prisma.schema
のパスを明記して、Prismaにスキーマファイルの場所を教えてあげる必要があります。
mkdir src/infrastructure # インフラストラクチャ層のディレクトリ
mv prisma src/infrastructure
{
+ "prisma": {
+ "schema": "src/infrastructure/prisma/schema.prisma"
+ }
}
続いて、ローカルで起動しているDBからスキーマファイルを生成します。
# DBからスキーマファイルを生成
npx prisma db pull
"scripts": {
+ "db:generate": "npx prisma generate && npx prisma-case-format --file src/infrastructure/prisma/schema.prisma"
}
# パッケージをインストール
npm install prisma-case-format --save-dev
# `prisma.schema` のケースを変換
npm run db:generate
最後にPrismaクライアントを設定します。Prismaではクライアントのインスタンスごとにコネクションプールを管理するため、常に単一のインスタンスが参照される必要があります。
import { PrismaClient } from "@prisma/client";
export const prismaClient = new PrismaClient({
// 環境変数からデータベースURLを設定
datasourceUrl: process.env.DATABASE_URL,
});
Vitestのセットアップ
テストランナーとして、Vitestをセットアップしていきます。
# vite-tsconfig-pathsは絶対パスのインポートを使うのに必要
npm install vitest vite-tsconfig-paths --save-dev
インストールが完了したら、vitest.config.ts
の作成と tsconfig.json
・pakage.json
の調整を行います。
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
globals: true,
},
resolve: {
alias: {
"@/": new URL("./src/", import.meta.url).pathname,
},
},
});
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["node", "vitest/globals"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
"scripts": {
+ "test": "vitest"
},
テスト環境の整備
まずはVitestでテストが実行できることを確認するため、DBへの永続化を責務とするリポジトリクラスとそのテストを書いていきます。
export class ActorRepository {
constructor(private prismaClient: PrismaClient) {}
async save(args: Prisma.ActorCreateArgs) {
return this.prismaClient.actor.create(args);
}
}
describe("ActorRepository", () => {
describe("save", () => {
test("should save an actor", async () => {
// 準備
const actorRepository = new ActorRepository(prismaClient);
const data: Prisma.ActorCreateInput = {
firstName: "Alice",
lastName: "Carroll",
};
const expectedActorCount = (await prismaClient.actor.count()) + 1;
// 実行
const createdActor = await actorRepository.save({ data });
// 保存の結果、テーブルのレコードが1件増えたことを検証
expect(await prismaClient.actor.count()).toBe(expectedActorCount);
// 追加されたレコードのデータを検証
expect(createdActor).toEqual(
await prismaClient.actor.findUnique({
where: { actorId: createdActor.actorId },
})
);
});
});
});
このテストは単体で動かす分には問題ないのですが、Actorテーブルを操作する他のテストを追加すると問題が生じます。Vitestはファイル単位でテストを並列実行 (Worker Threads上で動作) してくれるのですが、複数のテストが共通のDBに対して読み書きを行うため、相互に影響を与えてしまいます。
試しに actor-repository.test.ts
をコピーして、2件のテストファイルを同時に実行してみましょう。実行中にレコードの件数が変わってしまうため、後から実行されたテストは失敗します。
テストの並列実行を可能にする
上記の問題に対して、オプションで --maxWorkers=1
を指定 (並列実行をオフ) したうえで、クリーンアップ処理を挟むことでエラーは解消できます。しかし、将来的にテストが増えたときのことを考えると、Worker Threadsによる並列実行を活かして実行時間を短くしておきたいです。
解決方法を調べた結果、jest-prismaというライブラリがテストケース単位のロールバック機能を提供していました。
ライブラリ名に jest
とある通り、Vitest環境で動かすにはCustom Environmentを用意する必要があります。vitest-environment-vprismaがこれらのコードを提供してくれているのですが、今回は自分で設定ファイルを追加することにしました。
npm install --save-dev @quramy/jest-prisma-core
// Custom Environment
import type { PrismaClient } from "@prisma/client";
import { PrismaEnvironmentDelegate } from "@quramy/jest-prisma-core";
import { builtinEnvironments, type Environment } from "vitest/environments";
declare global {
var vPrismaDelegate: PrismaEnvironmentDelegate;
var vPrisma: {
client: PrismaClient;
};
}
const environment: Environment = {
name: "vprisma",
transformMode: "ssr",
async setup(global, options) {
const env = builtinEnvironments["node"];
const envReturn = await env.setup(global, {});
const delegate = new PrismaEnvironmentDelegate(
{
projectConfig: {
testEnvironmentOptions: options,
},
globalConfig: {
rootDir: "",
},
},
{
testPath: "",
}
);
global.vPrismaDelegate = delegate;
global.vPrisma = await delegate.preSetup();
return {
async teardown(global) {
await delegate.teardown();
await envReturn.teardown(global);
},
};
},
};
export default environment;
vi.mock("@/infrastructure/prisma/client", () => ({
prismaClient: vPrisma.client,
}));
beforeEach(async () => {
await Promise.all([
global.vPrismaDelegate.handleTestEvent({ name: "test_start" }),
global.vPrismaDelegate.handleTestEvent({ name: "test_fn_start" }),
]);
});
afterEach(async () => {
await Promise.all([
global.vPrismaDelegate.handleTestEvent({ name: "test_done" }),
global.vPrismaDelegate.handleTestEvent({
name: "test_fn_success",
test: { parent: null },
}),
]);
});
最後に追加したファイルを vitest.config.ts
に設定します。
test
// ...
+ environment: "./vitest-environment-vprisma.ts",
+ setupFiles: ["./vitest.setup.ts"],
},
これでDBへの永続化はトランザクション管理 (コミットされず、テスト終了後にロールバック) されるようになりました。テストを並列実行しても相互に影響を与えないため、問題なくパスすることが確認できます。
ファクトリ関数
テストデータの生成を簡単にするため、ファクトリ関数ををつくります。調べたところ、ライブラリを使用する方法、TypeScript Compiler APIを使って自作する方法などが見つかりましたが、今回は必要最低限の使い方ができれば十分と判断して、ユーティリティ関数の実装で対応することにしました。
import { PrismaClient, Prisma } from "@prisma/client";
import { prismaClient } from "@/infrastructure/prisma/client";
export const createFactory = <Model, CreateInput>(
modelName: Prisma.ModelName,
defaultAttributes: CreateInput
) => ({
create: async (attributes?: Partial<CreateInput>): Promise<Model> => {
const input = {
...defaultAttributes,
...attributes,
};
const model = `${modelName.charAt(0).toLowerCase()}${modelName.slice(1)}`;
const include = buildPrismaInclude(input);
return (prismaClient[model as keyof PrismaClient] as any).create({
data: input,
include: Object.keys(include).length > 0 ? include : undefined,
});
},
});
const buildPrismaInclude = (input: {}) => {
return Object.entries(input).reduce(
(prev: Record<string, Object | Boolean>, [key, value]) => {
if (value && typeof value === "object") {
if (
"create" in value &&
value.create &&
typeof value.create === "object"
) {
prev[key] = buildPrismaInclude(value.create);
} else {
prev[key] = true;
}
}
return prev;
},
{}
);
};
上記の関数を使用して、エンティティのファクトリ関数を用意します。デフォルト値にはFakerで生成した値をセットしています。
$ npm install --save-dev @faker-js/faker
import { fakerJA } from "@faker-js/faker";
import { Prisma, type Actor } from "@prisma/client";
import { createFactory } from "../helpers/factory.js";
export const actorDefaultAttributes: Prisma.ActorCreateInput = {
firstName: fakerJA.person.firstName(),
lastName: fakerJA.person.lastName(),
};
export const actorFactory = createFactory<Actor, Prisma.ActorCreateInput>(
"Actor",
actorDefaultAttributes
);
テストファイルではファクトリ関数を呼び出すだけでよいので、テストデータの生成がシンプルになりました。
// デフォルト値のまま
const actor1 = await actorFactory.create();
// 特定のデフォルト値を上書き
const actor2 = await actorFactory.create({
firstName: "Alice",
lastName: "Carroll",
});
CIの設定
最後にGitHub ActionsでCIを構築します。まずはローカル開発とテストで、DBサーバーのポートが競合している状況を解消する必要があります。ローカル開発では 5432
番ポート、テストでは 5433
番ポートを使うように変更しました。
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/dev_rental
DATABASE_PORT=5432
+ ports:
+ - "${DATABASE_PORT:-5432}:5432"
# コマンドラインからenvファイルを指定するため、`dotenv-cli` ライブラリをインストール
npm install --save-dev dotenv-cli
"scripts": {
- "test": "vitest",
+ "test": "dotenv -e .env.test -- vitest",
},
こちらのガイドを参考にして、アクションとワークフローを作成します。
# ビルド用のアクション
name: "Build"
runs:
using: "composite"
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
cache: "npm"
cache-dependency-path: "package-lock.json"
- name: Install dependencies
run: npm install
shell: bash
# Docker Composeコマンド実行用のアクション
name: "Docker Compose"
runs:
using: "composite"
steps:
- name: Download Docker Compose
shell: bash
run: curl -SL https://github.com//docker/compose/releases/download/v2.33.1/docker-compose-darwin-x86_64 -o /usr/local/bin/docker-compose
- name: Make binary executable
shell: bash
run: sudo chmod +x /usr/local/bin/docker-compose
- name: Start services
shell: bash
run: docker compose up -d
name: Test
on: [push, pull_request]
env:
ENV_FILE: .env.test
DATABASE_PORT: 5433
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/build
- uses: ./.github/actions/docker-compose
- name: Wait for PostgreSQL to start
run: sleep 5
- name: Run tests
run: npm run test
CIが成功していればOKです。
おわりに
今回はPrisma × Vitestのテスト環境構築手順を紹介しました。アプリケーション開発において、本物のDBを使用したテストは信頼性の観点からも重要だと思うので、プロジェクト開始時に整えておきたいところです。試行錯誤しながら設定した部分も多いため、いろいろな方法を試しながら、よりよいテスト環境を模索していきたいと思います。
Discussion