Zenn
📐

Prisma × Vitestのテスト環境構築

2025/03/10に公開

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を設定します。

.env
# 環境変数を設定
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dvd_rental

初期化コマンドではプロジェクトのルートに prisma ディレクトリが生成されるため、管理しやすいディレクトリに移動します。そのためには、package.jsonprisma.schema のパスを明記して、Prismaにスキーマファイルの場所を教えてあげる必要があります。

mkdir src/infrastructure # インフラストラクチャ層のディレクトリ
mv prisma src/infrastructure
package.json
{
+ "prisma": {
+   "schema": "src/infrastructure/prisma/schema.prisma"
+ }
}

続いて、ローカルで起動しているDBからスキーマファイルを生成します。

# DBからスキーマファイルを生成
npx prisma db pull
package.json
  "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ではクライアントのインスタンスごとにコネクションプールを管理するため、常に単一のインスタンスが参照される必要があります。

@/infrastractur/prisma/client.ts
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.jsonpakage.json の調整を行います。

vitest.config.ts
 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,
     },
   },
 });
tsconfig.json
{
  "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/*"]
    }
  }
}
package.json
"scripts": {
+ "test": "vitest"
},

テスト環境の整備

まずはVitestでテストが実行できることを確認するため、DBへの永続化を責務とするリポジトリクラスとそのテストを書いていきます。

@/infrastructure/repositories/actor-repository.ts
export class ActorRepository {
  constructor(private prismaClient: PrismaClient) {}

  async save(args: Prisma.ActorCreateArgs) {
    return this.prismaClient.actor.create(args);
  }
}
@/infrastructure/repositories/actor-repository.test.ts
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というライブラリがテストケース単位のロールバック機能を提供していました。

https://quramy.medium.com/integrated-testing-with-prisma-4bc73404d027

ライブラリ名に jest とある通り、Vitest環境で動かすにはCustom Environmentを用意する必要があります。vitest-environment-vprismaがこれらのコードを提供してくれているのですが、今回は自分で設定ファイルを追加することにしました。

npm install --save-dev @quramy/jest-prisma-core
vite-environment-vprisma.ts
// 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;
vitest.setup.ts
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 に設定します。

vitest.config.ts
  test
    // ...
+   environment: "./vitest-environment-vprisma.ts",
+   setupFiles: ["./vitest.setup.ts"],
  },

これでDBへの永続化はトランザクション管理 (コミットされず、テスト終了後にロールバック) されるようになりました。テストを並列実行しても相互に影響を与えないため、問題なくパスすることが確認できます。

ファクトリ関数

テストデータの生成を簡単にするため、ファクトリ関数ををつくります。調べたところ、ライブラリを使用する方法、TypeScript Compiler APIを使って自作する方法などが見つかりましたが、今回は必要最低限の使い方ができれば十分と判断して、ユーティリティ関数の実装で対応することにしました。

https://zenn.dev/seya/articles/5d384daafb1c24#typescript-compiler-api-で生成する
https://github.com/Quramy/prisma-fabbrica

@/__tests__/helpers/factory.ts
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
/__tests__/factories/actor-factory.ts
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 番ポートを使うように変更しました。

.env.test
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/dev_rental
DATABASE_PORT=5432
compose.yaml
+   ports:
+     - "${DATABASE_PORT:-5432}:5432"
# コマンドラインからenvファイルを指定するため、`dotenv-cli` ライブラリをインストール
npm install --save-dev dotenv-cli
package.json
  "scripts": {
- "test": "vitest",
+ "test": "dotenv -e .env.test -- vitest",
  },

こちらのガイドを参考にして、アクションとワークフローを作成します。

https://www.prisma.io/blog/testing-series-5-xWogenROXm

.github/actions/build/action.yaml
# ビルド用のアクション
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
.github/actions/docker-compose/action.yaml
# 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

.github/workflows/test.yaml
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を使用したテストは信頼性の観点からも重要だと思うので、プロジェクト開始時に整えておきたいところです。試行錯誤しながら設定した部分も多いため、いろいろな方法を試しながら、よりよいテスト環境を模索していきたいと思います。

株式会社FLAT テックブログ

Discussion

ログインするとコメントできます