概要
コンテナを立ち上げてDBテストを行ってみた。
テーブルの初期化がテストに含まれていると可読性が悪い。次の記事で解決した。
ソース
apps/backend/package.json
{
"scripts": {
"test": "vitest --config vitest.config.worker.mts",
+ "integration-test": "DEBUG=testcontainers* vitest run --config vitest.config.integration.mts"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.8.11",
"@cloudflare/workers-types": "^4.20250405.0",
+ "@testcontainers/postgresql": "^10.24.0",
"dotenv": "^16.4.7",
+ "pg": "^8.14.1",
+ "testcontainers": "^10.24.0",
"vitest": "3.1.1",
"wrangler": "^4.7.2"
},
"dependencies": {
"@hono/valibot-validator": "^0.5.2",
"hono": "^4.7.5",
"@odyssage/database": "workspace:*",
"@odyssage/lib": "workspace:*",
"@odyssage/schema": "workspace:*"
}
}
apps/backend/test/integration.spec.ts
import {
PostgreSqlContainer,
StartedPostgreSqlContainer,
} from '@testcontainers/postgresql';
import { Hono } from 'hono';
import { Client } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { sessionRoute } from '../src/route/session';
import { generateUUID } from '../src/utils/generateUUID';
/**
* セッション関連のエンドポイントに対する統合テスト
* Testcontainersを使用して実際のPostgreSQLコンテナを起動し、
* データベース操作を含む統合テストを実行します
*/
describe('セッション統合テスト', () => {
let postgresContainer: StartedPostgreSqlContainer;
let connectionString: string;
let pgClient: Client;
let app: Hono;
// テストユーザーとシナリオの情報
const testUserId = 'test-user-id';
const testUserName = 'テストユーザー';
const testScenarioId = generateUUID();
const testScenarioTitle = 'テストシナリオ';
// テスト開始前にPostgreSQLコンテナを起動
beforeAll(async () => {
postgresContainer = await new PostgreSqlContainer('postgres:17.4-alpine')
.withDatabase('test_db')
.withUsername('test_user')
.withPassword('test_password')
.start();
connectionString = postgresContainer.getConnectionUri();
// PGクライアントを作成してテスト用のテーブルを初期化
pgClient = new Client({
connectionString,
});
await pgClient.connect();
// スキーマとテーブルを作成
await pgClient.query(`CREATE SCHEMA odyssage;`);
await pgClient.query(`
CREATE TABLE odyssage.users (
id VARCHAR(64) PRIMARY KEY,
name TEXT NOT NULL DEFAULT ''
);
`);
await pgClient.query(`
CREATE TABLE odyssage.scenarios (
id UUID PRIMARY KEY,
title TEXT NOT NULL,
user_id VARCHAR(64) NOT NULL REFERENCES odyssage.users(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
overview TEXT NOT NULL DEFAULT '',
visibility VARCHAR(10) NOT NULL DEFAULT 'private'
);
`);
await pgClient.query(`
CREATE TABLE odyssage.sessions (
id UUID PRIMARY KEY,
gm_id VARCHAR(64) NOT NULL REFERENCES odyssage.users(id) ON DELETE CASCADE,
scenario_id UUID NOT NULL REFERENCES odyssage.scenarios(id) ON DELETE CASCADE,
title TEXT NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT '準備中',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
`);
// テストデータを作成
await pgClient.query(
`
INSERT INTO odyssage.users (id, name) VALUES ($1, $2)
`,
[testUserId, testUserName],
);
await pgClient.query(
`
INSERT INTO odyssage.scenarios (id, title, user_id) VALUES ($1, $2, $3)
`,
[testScenarioId, testScenarioTitle, testUserId],
);
// アプリを初期化
app = new Hono();
app.route('/api/sessions', sessionRoute);
}, 60000); // コンテナ起動に時間がかかるためタイムアウトを延長
// テスト終了後にコンテナを停止
afterAll(async () => {
await pgClient.end();
await postgresContainer.stop();
});
it('セッションを作成して正しく取得できること', async () => {
const sessionData = {
gm_id: testUserId,
scenario_id: testScenarioId,
title: 'テストセッション',
};
const env = {
CLOUDFLARE_ENV: 'test',
NEON_CONNECTION_STRING: connectionString,
};
const postResponse = await app.request(
'/api/sessions',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(sessionData),
},
env, // 第3引数に入れると環境変数を設定できる
);
expect(postResponse.status).toBe(201);
const createdSession = (await postResponse.json()) as any;
const getResponse = await app.request(
`/api/sessions/${createdSession.id}`,
undefined,
env,
);
expect(getResponse.status).toBe(200);
});
});
apps/backend/vitest.config.integration.mts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/integration.spec.ts'],
// 統合テストではNode.jsのAPIを使用するので、ポリフィルを有効化
deps: {
interopDefault: true,
},
environment: 'node',
environmentOptions: {
// Bunでfs/promisesなどのNode.jsモジュールを利用可能にする
testBunIntegration: true,
},
// コンソールログを抑制する設定
silent: false,
// テストタイムアウトを延長(Testcontainersはコンテナの起動に時間がかかる)
testTimeout: 60000,
},
});
neonのプロキシをDockerでうまく上げられなかったので、testcontainersはdrizzle-orm/postgres-js
を使ってアクセスすることにした。*
packages/datbase/src/db.ts
import { neon, neonConfig } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';
+ import { drizzle as nodePostgresDrizzle } from 'drizzle-orm/postgres-js';
import type { NeonQueryFunction } from '@neondatabase/serverless';
import type { NeonHttpDatabase } from 'drizzle-orm/neon-http';
type NeonDBClient = NeonHttpDatabase<Record<string, never>> & {
$client: NeonQueryFunction<false, false>;
};
let db: NeonDBClient | null = null;
export const getDb = (
connectionString = process.env.NEON_CONNECTION_STRING!,
) => {
if (db != null) {
return db;
}
+ if (connectionString.startsWith('postgres://test_user')) {
+ console.log('testcontainersで起動したPostgreSQLコンテナを使用します');
+ db = nodePostgresDrizzle(connectionString) as unknown as NeonDBClient;
+ return db;
+ }
if (
connectionString ===
'postgres://postgres:postgres@db.localtest.me:5432/main'
) {
console.log('ローカルのPostgreSQLコンテナを使用します');
neonConfig.fetchEndpoint = `http://db.localtest.me:4444/sql`;
neonConfig.useSecureWebSocket = false;
}
const sql = neon(connectionString);
db = drizzle({ client: sql });
return db;
};
参考
Testcontainersを用いたNext.jsとDBの結合テスト
Testcontainersで実現する、使い捨て結合テスト環境構築とテスト実施
Neon(Postgres)のDockerをローカルで稼働させ、Drizzleを使ったマイグレーションとCloudflare Workersのローカル実行からの接続を行ったメモ