使い捨てテストDB環境のtestcontainersをPostgresql + Honoで試してみたメモ

# 概要
コンテナを立ち上げてDBテストを行ってみた。

[この時のソース](https://github.com/hibohiboo/odyssage/blob/2096bec8f38573228e944a25aeed3991168abcfe/apps/backend/test/integration.spec.ts)

テーブルの初期化がテストに含まれていると可読性が悪い。[次の記事](https://qiita.com/hibohiboo/items/f267dd785ec30458fa0f)で解決した。

# ソース

```diff: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:*"
  }
}
```

```ts: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);
  });
});
```

```ts: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`を使ってアクセスすることにした。[*](https://orm.drizzle.team/docs/get-started-postgresql)

```diff: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の結合テスト](https://zenn.dev/silverbirder/articles/bcf9ae9b496a15)
[Testcontainersで実現する、使い捨て結合テスト環境構築とテスト実施](https://zenn.dev/silverbirder/articles/0a54533cb7ee9b)

[Neon(Postgres)のDockerをローカルで稼働させ、Drizzleを使ったマイグレーションとCloudflare Workersのローカル実行からの接続を行ったメモ](https://qiita.com/hibohiboo/items/1827c41ce162431fda3e)

[testcontainers](https://testcontainers.com/modules/postgresql/?language=nodejs)
[postgresql - docker](https://hub.docker.com/_/postgres)