Closed8

Prisma使っててテストをやるときどうすればいいの

5t1111115t111111

Prisma を使っていて、テストをどうするか考えている。
Prisma でテストをやるときの方法について全然情報がない。

実際にデータベースを使ったテストがしたい

PrismaClient をがんばってモックしてデータベースアクセスが発生しないようにすることもできなくはないが、Prisma がまだ若いライブラリであることもあり、ユニットテストの時点でもライブラリの実際の挙動をテストできた方が嬉しいと思っている。

テストのときに使うデータベースはテスト用のものにしたい

例えば、数少ない情報の中でこの記事では、Docker で Node と MySQL コンテナを動かしてそこでテストをやる手順が書いてある。

https://dev.to/eddeee888/how-to-write-tests-for-prisma-with-docker-and-jest-593i

テスト用のデータベースは開発用のものとはわけたい。当然です。でも、Prisma ではテスト用に接続先の DB を変えるということがやりにくい (要は DATABASE_URL を切り替えるのがだるい) 。なので上記のポストはそれに対応するためのものである。

これはその目的のためにはうまくいきそう。とはいえ、環境変数の切り替えのためだけにやることとしてはちょっと面倒すぎるという感じがしている。

普通にシェルから jest を実行するときに

DATABASE_URL="mysql://root:root@localhost:33306/foobar" npm test

とするのでいいんじゃないかという気がしていて、実際そうしようと思う。

5t1111115t111111

Nest.js で Prisma を使っているときにテストの実行後に接続が切れない

Nest.js で Prisma を使うときは、まずはおそらく公式ドキュメントにあるこの方法で使うパターンがほとんどだと思う。

https://docs.nestjs.com/recipes/prisma

でもこれでやると、DB アクセスが発生するテストを jest で実行したときに接続が切れなかった。待ち状態になって jest がタイムアウトする。

Nest.js のライフサイクルでプロバイダーの onModuleDestroySIGTERM とかを受けたときに実行されるシグナルハンドラのようなフックのようだ。

https://docs.nestjs.com/fundamentals/lifecycle-events

よくわかってないけど、まあ直接 onModuleDestroy を呼べば $disconnect() されるだろうと思って雑に↓のような感じで解決した。実際問題は解決した。

  afterAll(async () => {
    await (service as any).prismaService.onModuleDestroy();
  });

これは一旦これでよし

5t1111115t111111

テストを実行する前にマイグレーションを実行したい

テストを実行するときに必ずマイグレーションが走るようにしたい。

マイグレーションは test suites 全体を実行する前に1度実行すれば十分なので、以下のようにテストを実行することで対応することにした。

npx prisma migrate reset --force && npm test src/modules/albums

実際には ここに書いているように 、接続先 DB の切り替えのために各コマンドの前に環境変数を設定するようにして実行する。

prisma migrate を実行すると Prisma クライアントも自動生成される。

5t1111115t111111

テストを実行する前にデータベースの掃除がしたい

テストを実行するときは、データベースに前のテストのデータが残っていたりとかがない綺麗な状態になっていて欲しいので、データベースを掃除するようにしたい。

遅くはなるだろうが、これはすべてのテストの前に実行すべき。

ここにやり方が書いてあったが、全部動かなかった :cry:
https://github.com/prisma/docs/issues/451

ここで書かれているアプローチは2つある。

  • Reflect.ownKeys(Object.getPrototypeOf(prisma)) で PrismaClient のリフレクションからモデル名を取得する方法
    • → PrismaClient のプロトタイプのキーにモデル名はない (仕様変更なのかトランスパイルの結果が異なるのかなどは調べてない)
  • dmmf (data model meta format の略らしい) からモデル情報を取得する方法
    • import { dmmf } from '@prisma/client'; がいきなりできない。export されてない

リフレクションに頼るよりも後者の dmmf を使う方法の方が良さそうだが、いずれにせよどっちも動かないのでどうしようもない。

とりあえず @prisma/client の型定義を眺めながら、どうやら以下のいずれかで修正すれば動くことがわかった。

import { PrismaClient, Prisma } from '@prisma/client';

export const cleanupDatabase = () => {
  const prisma = new PrismaClient();
  const modelNames = Prisma.dmmf.datamodel.models.map((model) => model.name);

  return Promise.all(
    modelNames.map((modelName) => prisma[modelName.toLowerCase()].deleteMany())
  );
};

or

import { PrismaClient } from '@prisma/client';

export const cleanupDatabase = () => {
  const prisma = new PrismaClient();
  const propertyNames = Object.getOwnPropertyNames(prisma);
  const modelNames = propertyNames.filter(
    (propertyName) => !propertyName.startsWith('_')
  );

  return Promise.all(modelNames.map((model) => prisma[model].deleteMany()));
};

適当な修正だが一応コメントしておいた。

https://github.com/prisma/docs/issues/451#issuecomment-812424220

jest から実行すると、さっきのタイムアウト問題が発生したので、最終的には↓を使うことにしてみた。

import { PrismaClient, Prisma } from '@prisma/client';

/**
 * 全てのテーブルのデータを削除する
 * 参考: https://github.com/prisma/docs/issues/451
 */
export const cleanupDatabase = async (): Promise<void> => {
  const prisma = new PrismaClient();
  const modelNames = Prisma.dmmf.datamodel.models.map((model) => model.name);

  await Promise.all(
    modelNames.map((modelName) => prisma[modelName.toLowerCase()].deleteMany())
  );

  prisma.$disconnect();
};

テストケース側では普通にこう

  beforeEach(async () => {
    await cleanupDatabase();
    // ... いろいろ
  });
5t1111115t111111

データベースの掃除には、ここで書いたように個別テーブルを削除していくのではなくmigrate reset を使った方が効率的だしシンプルに解決できそうということがわかった。

したがって、以下のような処理をテスト実行前に流す。

import util from 'util';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const exec = util.promisify(require('child_process').exec);

export const resetDatabase = async (): Promise<void> => {
  const prismaBinary = './node_modules/.bin/prisma';
  await exec(`${prismaBinary} migrate reset --force`);
};

ちなみに MySQL の場合だけ確認したが、migrate reset の実装はごくシンプルで、DROP DATABASE / CREATE DATABASE をしているだけの様子。

https://github.com/prisma/prisma-engines/blob/f461292a2242db52d9f4c87995f0237aacd300d2/migration-engine/connectors/sql-migration-connector/src/flavour/mysql.rs#L260-L268

5t1111115t111111

↑のコメントで書いていた方法だと、例えば VSCode や WebStorm などからテストを実行する、つまりシェルを経由しないテスト実行の際に、migrate reset が内部的に実行している seeding で ts-node が見つからないエラーが出た。

以下に変更することで回避している。

import util from 'util';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const exec = util.promisify(require('child_process').exec);

export const resetDatabase = async (): Promise<void> => {
  await exec('npx migrate reset --force');
};
5t1111115t111111

今になっても結構このスクラップに LIKE をつけてくれる人がいるので (ありがとうございます)、これを書いてから2年くらい経った今どういうやり方でやっているか簡単に書いておきたいと思います。

基本的な構成と考え方

考え方はあんまり変わっていません。

  • データベースはテスト用のデータベースを1つだけ用意している
    • 個別のテストごとにデータベースを用意することはしていない
    • docker compose でテスト用と開発用が立ち上がるようにしている (大抵の場合は Dev container と一緒に使う)
  • ユニットテストでも Prisma Client の API 操作のスタブはできるだけしない
    • 後述のユニットテスト用のツール (vitest-environment-vprisma) によってモック化はしているが、実際に実行される API は Prisma Client のそれであり、実際のデータベースを操作するようにしている
  • このスクラップを書いたときは Nest.js で Prisma を使う前提だったが、今はあまり Nest.js を使っていないので PrismaService などを前提にした構成になっていない (インスタンス化した PrismaClient インスタンスをシングルトンでアプリケーションのコンテキストに生やす)

ユニットテスト

  • テスト用のフレームワークには Vitest を使っている
  • ユニットテスト用の環境変数セット (.env.test) を用意し、それを読み込むようにしている
    • 実行例: dotenv -e .env.test vitest run
  • vitest-environment-vprisma を使って、個別のテストケースがトランザクション実行/ロールバックされるようにしており、それをテスト前後のデータベースの掃除としている
    • アプリケーションはシングルトンの Prisma Client インスタンスを使うように構成して、このツールでモック化する
  • テストデータの準備にはそれ用のツールなどは使っていない
    • 普通に PrismaClient の upsert を使ってデータを作成している
    • faker くらいは使うが、factory_bot 的な仕組みを導入していないということ
  • テストを実行する前に1度 prisma db push を実行するようにしている
    • prisma migrate reset ではない
    • これは単純に PlanetScale を使うことがあるのでマイグレーションが使いにくいため (本当は使いたい)

E2E テスト

  • Playwright を使っている
  • E2E テストは基本的に開発環境に対して実行されるようにしている
    • つまり、 .env.test は E2E では使われない
  • E2E テストでのテストデータの掃除などは仕組み化していない。汚れても DB をリセットして seed を読み込むので済むことが多いので
このスクラップは2022/10/28にクローズされました