Testcontainersを利用し実際のデータベースを用いたテストを並列実行する
2025 / 01 / 20
Edit自分はDBをモックしてテストを行うのが嫌いですが、もし実際のDBを利用し並列実行する場合にポートやtruncate周りの問題が発生します。 今回は、それを解決するためにTestcontainersとVitestを利用したコードで解決します。
Testcontiners
Docker社が買収したTestcontainersは、Dockerコンテナをテストで利用しやすくするライブラリです。
開発時にdocker compose
を利用している人が多いと思うので、そこで利用しているcompose.yaml
の定義をまんまテストで再利用できる点も便利かなと思います。

ポート衝突の問題
一般的に開発時は固定のポートを利用するため、テストを並列で実行する場合にポートが衝突する問題が発生します。
なので、${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", () => {
// ...
}):
コード全体の一覧は以下のページやリポジトリを参照