Testcontainersを利用し実際のデータベースを用いたテストを並列実行する

2025 / 01 / 20

Edit

自分はDBをモックしてテストを行うのが嫌いですが、もし実際のDBを利用し並列実行する場合にポートやtruncate周りの問題が発生します。 今回は、それを解決するためにTestcontainersとVitestを利用したコードで解決します。

Testcontiners

Docker社が買収したTestcontainersは、Dockerコンテナをテストで利用しやすくするライブラリです。 開発時にdocker composeを利用している人が多いと思うので、そこで利用しているcompose.yamlの定義をまんまテストで再利用できる点も便利かなと思います。

Testcontainers Testcontainers is an opensource library for providing lightweight, throwaway instances of common dat...

ポート衝突の問題

一般的に開発時は固定のポートを利用するため、テストを並列で実行する場合にポートが衝突する問題が発生します。

なので、${DATABASE_PORT:-5432}:5432と書くことによりDATABASE_PORTという環境変数を尊重し、ない場合はデフォルトのポートを参照するように変更します。 これにより、普段の開発時にはDATABASE_PORTを指定せず、テスト実行時だけそれぞれのテストスイートが実行時に指定できるようになります。

services:
  db:
    image: postgres:17
    ports:
      - ${DATABASE_PORT:-5432}:5432
    environment:
      - POSTGRES_USER=${DATABASE_USER}
      - POSTGRES_PASSWORD=${DATABASE_PASSWORD}
      - POSTGRES_DB=${DATABASE_DB}

この環境変数をTestcontainersでコンテナ起動時に上書きすることによりポートを動的に切り替えます。

// https://github.com/hiroppy/web-app-template/blob/main/tests/db.setup.ts

import { exec } from "node:child_process";
import { promisify } from "node:util";
import { Prisma, PrismaClient } from "@prisma/client";
import { DockerComposeEnvironment, Wait } from "testcontainers";
import { createDBUrl } from "../src/app/_utils/db";

const execAsync = promisify(exec);

const container = await new DockerComposeEnvironment(".", "compose.yml")
  .withEnvironmentFile(".env.test")
  .withEnvironment({
    DATABASE_PORT: port === "random" ? "0" : `${port}`, // 環境変数を差し込み、ポートを動的に変更
  })
  .withWaitStrategy("db", Wait.forListeningPorts())
  .up(["db"]);
const dbContainer = container.getContainer("db-1");
// これで実際にhost側にbindされたランダムなポートを得る
const mappedPort = dbContainer.getMappedPort(5432);
const url = createDBUrl({
  host: dbContainer.getHost(),
  port: mappedPort,
});

// migration
await execAsync(`DATABASE_URL=${url} npx prisma db push`);

const prisma = new PrismaClient({
  datasources: {
    db: {
      // prisma clientの初期URLはschemaに書かれたものなので上書きする必要がある
      url,
    },
  },
});

これにより、prismaのクライアント自体もURLを切り替えた状態で作成でき、テスト内ではこのクライアントを利用します。 そしてそれぞれのテストスイート事にDBが隔離され、安全に並列実行できるようになります。

実際のコード例

compose.yml

volumes:
  db-data:

services:
  db:
    image: postgres:17
    ports:
      - ${DATABASE_PORT:-5432}:5432
    environment:
      - POSTGRES_USER=${DATABASE_USER}
      - POSTGRES_PASSWORD=${DATABASE_PASSWORD}
      - POSTGRES_DB=${DATABASE_DB}

db.setup.ts

これはunitだけでなく、e2eでも利用可能なので自分は汎用的にしています。

import { exec } from "node:child_process";
import { promisify } from "node:util";
import { Prisma, PrismaClient } from "@prisma/client";
import { DockerComposeEnvironment, Wait } from "testcontainers";
import { createDBUrl } from "../src/app/_utils/db";

const execAsync = promisify(exec);

export async function setupDB({ port }: { port: "random" | number }) {
  const container = await new DockerComposeEnvironment(".", "compose.yml")
    .withEnvironmentFile(".env.test")
    // overwrite environment variables
    .withEnvironment({
      DATABASE_PORT: port === "random" ? "0" : `${port}`,
    })
    .withWaitStrategy("db", Wait.forListeningPorts())
    .up(["db"]);
  const dbContainer = container.getContainer("db-1");
  const mappedPort = dbContainer.getMappedPort(5432);
  const url = createDBUrl({
    host: dbContainer.getHost(),
    port: mappedPort,
  });

  await execAsync(`DATABASE_URL=${url} npx prisma db push`);

  const prisma = new PrismaClient({
    datasources: {
      db: {
        url,
      },
    },
  });

  async function down() {
    await prisma.$disconnect();
    await container.down();
  }

  return <const>{
    container,
    port,
    prisma,
    truncate: () => truncate(prisma),
    down,
    async [Symbol.asyncDispose]() {
      await down();
    },
  };
}

export async function truncate(prisma: PrismaClient) {
  const tableNames = Prisma.dmmf.datamodel.models.map((model) => {
    return model.dbName || model.name.toLowerCase();
  });
  const truncateQuery = `TRUNCATE TABLE ${tableNames.map((name) => `"${name}"`).join(", ")} CASCADE`;

  await prisma.$executeRawUnsafe(truncateQuery);
}

vitest.helper.ts

実際のDBセットアップからtruncate、downまでをまとめたヘルパー関数です。vi.hoistedを利用する必要があります。

import type { User } from "next-auth";
import { afterAll, afterEach, expect, vi } from "vitest";

export async function setup() {
  const { container, prisma, truncate, down } = await vi.hoisted(async () => {
    const { setupDB } = await import("../../../tests/db.setup");

    return await setupDB({ port: "random" });
  });

  vi.mock("../_clients/prisma", () => ({
    prisma,
  }));

  afterAll(async () => {
    await down();
  });

  afterEach(async () => {
    await truncate();
  });

  return <const>{
    container,
    prisma,
    truncate,
    down,
    mock,
  };
}

テストスイート

import { beforeEach, describe, expect, test } from "vitest";
import { setup } from "./test.helper";

const { prisma, mock, createUser } = await setup();

describe("actions/items", () => {
  // ...
}):

コード全体の一覧は以下のページやリポジトリを参照

Unit Testing | Web App Template From Zero to Service: Build with Best Practices, Minimal Code, and Essential Tools.

GitHub - hiroppy/web-app-template: A minimal template for web app including DataBase, Google Auth, and frontend infrastructure 🎃 A minimal template for web app including DataBase, Google Auth, and frontend infrastructure 🎃 - hir...