
Testcontainersを利用し実際のデータベースを用いたテストを並列実行する
目次
自分は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 databases, Selenium web browsers, or anything else that can run in a Docker container.

ポート衝突の問題
一般的に開発時は固定のポートを利用するため、テストを並列で実行する場合にポートが衝突する問題が発生します。
なので、${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,});
// migrationawait 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 web service template 🎃 "npx create-app-foundation@latest" !
A minimal web service template 🎃 "npx create-app-foundation@latest" ! - hiroppy/web-app-template