🙌

NestJS+Prisma+Jestで実際のDBMSを使用した自動テスト

2024/02/07に公開
1

経緯

実際のDBMSを用いたテストを書く際に、ベストプラクティス的なものが見つからず試行錯誤。
そんな中で自分なりの結論が出たため、同じ悩みを持った誰かの引き出しになればと思い記事を書きました。

前提

  • NestJS
  • Typescript
  • Prisma (schema.prismaがありnpx prisma db pushが通るようになっていること)
  • postgres (多分MySQLでも問題ないが、ここではpostgres)

この記事のゴール

  • テスト毎にデータが空になる(出来る)こと
  • GitHub Actionsで実行できること
  • Jestを並列で実行できること
  • npm run testで実行できること

下準備

Docker

docker-compose.yml
今回test-databaseとしてテスト用のDBを別で追加しました。

version: '3.9'
services:
  test-database:
    image: postgres:15.3
    restart: always
    container_name: integration-tests
    ports:
      - "5400:5432" # 競合を避けて5400としています
    environment:
      POSTGRES_DB: "tests"
      POSTGRES_USER: "prisma"
      POSTGRES_PASSWORD: "prisma"

package.json

package.jsontestはDockerが立ち上がるよう以下のようにします。

{
  ...
  "scripts": {
    ...
    "test": "docker-compose up -d && jest && docker-compose down",
  },
}

環境変数

自分の場合は.env.testというtest用のenvファイルを作成しましたが、
各々の環境に合わせ追加してください。

# Local DB
DATABASE_URL="postgresql://prisma:prisma@localhost:5400/tests"

ヘルパー関数の実装

jest.setup.tsというファイルにヘルパー関数を実装しました。

import { PrismaClient } from '@prisma/client';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import { execSync } from 'child_process';

/**
 * JEST_WORKDER_ID毎にDatabaseを作成し、データのリセット処理を行う。
 */
export async function setupDatabase() {
  // 作成するDB名
  const newDbName = `worker_${process.env.JEST_WORKER_ID}`;

  // DBの作成
  const prisma = new PrismaClient();
  await prisma.$connect();
  try {
    await prisma.$executeRaw`CREATE DATABASE ${newDbName}`;
  } catch (error) {
    if (error instanceof PrismaClientKnownRequestError) {
      // DB作成済みだった場合は無視
      // 本来はここでエラーコードをチェックした方が良い。今回は割愛
    } else {
      throw error;
    }
  }
  await prisma.$disconnect();

  // 環境変数上書き
  const dbUrl = new URL(process.env.DATABASE_URL ?? '');
  const baseUrl = dbUrl.href.substring(0, dbUrl.href.lastIndexOf('/'));
  process.env.DATABASE_URL = `${baseUrl}/${newDbName}`;

  // DB初期化処理
  execSync('npx prisma migrate reset --force --skip-seed', {
    env: {
      ...process.env,
    },
  });
  execSync('npx prisma db push', {
    env: {
      ...process.env,
    },
  });
}

これでsetupDatabase関数を呼び出すことで任意のタイミングでDBをリセット出来るようになりました。

execSyncを使用した初期化やJEST_WORKER_ID毎にDBを作るというアイデアはこちらの記事を参考にしています。
https://www.mizdra.net/entry/2022/11/24/153459

使用例

beforeAllで一回だけ初期化

describe('UserService Integration Test', () => {
    let service: UserService;
    let prisma: PrismaService;

    beforeAll(async () => {
        // 最初の一回だけ初期化
        await setupDatabase();

        const module: TestingModule = await Test.createTestingModule({
            providers: [
                UserService,
                PrismaService,
                LoggingService,
            ],
        }).compile();

        service = module.get<UserService>(UserService);
        prisma = module.get<PrismaService>(PrismaService);

        // データベースにテスト用データの挿入
        await prisma.userEntity.create({
            data: {
                id: 1,
                name: 'TEST_USER'
            },
        });
    });
}, 1000); // setupDatabaseの時間がかかる場合はこのようにタイムアウト時間を伸ばしてください

it('ユーザーを取得できること', async () => {
    const user = await service.getUser(1);// DBからID:1を取得する処理
    expect(user.id).toBe(1);
    expect(user.name).toBe('TEST_USER');
});

jest.setup.tsで自動的に初期化する

  1. setupFilesAfterEnvjest.setup.tsを追加
    jest.config.ts
module.exports = {
    ...,
    setupFilesAfterEnv: ['jest.setup.ts']
}
  1. jest.setup.tsbeforeAllsetupDatabaseを呼び出し
    jest.setup.ts
beforeAll(async () => {
    await setupDatabase();
});

このようにすることでテストファイル毎に自動でリセットされるので便利です。
しかし 自分の環境ではDBに関連しない関数のテストも含まれていた為、jest.setup.tsではなくテスト毎に呼び出す方式を採用しました。

utilsなどのテストもあると思うので、面倒ですがテスト毎に呼び出す方式が良いかと思います。

GitHub Actions (workflow)

name: Integration Test
on: pull_request
jobs:
  tests:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: npm

      - name: NPM install
        run: npm install

      - name: TSC
        run: tsc

      - name: Run Test
        run: npm run test

余談

今回追加したjest.setup.tsですが、テスト(*.spec.ts)以外からの呼び出しを禁止する為、 eslintに以下のような設定を加えました。
これにより本番コードからsetupDatabaseを呼び出される心配はありません。

module.exports = {
  // ...
  rules: {
    'no-restricted-imports': [
      'error', 
      {
        patterns: [
          {
            group: ['jest.setup'],
            message: 'src/jest.setup.tsはテストファイル(*.spec.ts)でのみインポート可能です。'
          }
        ]
      }
    ]
  },
  overrides: [
    {
      files: ['*.spec.ts'],
      rules: {
        'no-restricted-imports': 'off',
      },
    },
  ],
}

最後に

setupDatabaseはどうしても時間がかかってしまうのが気になりポイントですね。

DBの初期化周りはもっといい方法あると思っていますが、一旦諦めた感じです。

もっといい方法あるよ!って方がいたらコメントで教えて頂けると幸いです。

GitHubで編集を提案

Discussion

takecchitakecchi
  // DB初期化処理
  execSync('npx prisma migrate reset --force --skip-seed', {
    env: {
      ...process.env,
    },
  });

このDB初期化処理が非常に遅いので、

  const prisma = new PrismaClient();
  await prisma.$connect();
  try {
    const tables = await prisma.$queryRawUnsafe<{ table_name: string }[]>(
      `SELECT table_name
       FROM information_schema.tables
       WHERE table_schema = 'public'
         AND table_type = 'BASE TABLE';`,
    );
    await Promise.all(
      tables.map(({ table_name }) => {
        return prisma.$executeRawUnsafe(
          `TRUNCATE TABLE "${table_name}" RESTART IDENTITY CASCADE;`,
        );
      }),
    );
  } finally {
    await prisma.$disconnect();
  }

このように直接TRUNCATEすることで速度は上がりました。
※このコードはPostgreSQL用

手が空いたタイミングで更新します。