Portfolio logo
🧪

TypeScriptのAPIのテスト時のセットアップ

#typescript #api #testing #setup

背景

TypeScript + Playwright で API のインテグレーションテストを回すときは、常にリセット可能なデータベースが必要です。ここでは test/api 配下で運用しているセットアップ手順をそのまま共有します。Docker Compose で PostgreSQL を起動し、Playwright の globalSetup でデータを投入し、generateTableOrder.ts でテーブル作成順を自動生成する構成です。

全体構成

test/api/
├── playwright.config.ts         # globalSetup/globalTeardown を登録
├── scripts/
│   ├── database.ts              # MainDb クラス。初期データ投入とクリアを担当
│   ├── generateTableOrder.ts    # 依存関係からテーブル作成順を計算するスクリプト
│   ├── tableOrder.ts            # 上記スクリプトの出力(定数)
│   └── global.setup.ts          # Playwright 起動前に DB を初期化
└── setup/
    └── database/                # 各テストシナリオのデータセット
  1. docker-compose.yml で PostgreSQL(port 15432)を起動
  2. Playwright の globalSetup で DB を clear → setup
  3. generateTableOrder.ts で外部キー順にテーブルを並べ替え、挿入順と削除順を固定
  4. 各テストは事前に投入されたフィクスチャデータを前提に HTTP 契約を検証

1. Docker Compose で PostgreSQL を起動

docker-compose.yml には main_db_primary コンテナとマイグレーターが定義されています。ローカルでは次のコマンドで十分です

docker compose -f docker-compose.yml up -d main_db_primary migrator
  • main_db_primary: localhost:15432main データベースを公開
  • migrator: リポジトリのマイグレーションを最新化して終了(Playwright から同じボリュームを使い回せる)

テスト用 .envtest/api/scripts/config.ts がデフォルト値を持っているので、DB_PORT=15432 を変えたいときだけ環境変数で上書きします。

2. Playwright globalSetup で DB を初期化

playwright.config.ts では scripts/global.setup.tsglobalSetup に登録しています。

// test/api/scripts/global.setup.ts
import type { FullConfig } from "@playwright/test"
import { MainDb } from "~/scripts/database"

export default async function globalSetup(_config: FullConfig) {
  const db = new MainDb()
  await db.clear()
  await db.setup()
}

MainDbscripts/database.ts 内で定義されており、Kysely で Postgres に接続して setup/database 以下にあるフィクスチャをすべて流し込みます。代表的な部分だけ抜粋すると次のようになります(実際のファイルでは他にも setupXXX が列挙されています)。

// test/api/scripts/database.ts
import type { DB } from "~/infrastructure/database/schema"
import { type Insertable, Kysely, PostgresDialect } from "kysely"
import { Pool } from "pg"
import { setupAuthLogoutPost } from "~/setup/database/setupAuthLogoutPost"
import { setupSampleGet } from "~/setup/database/setupSampleGet"
import { databaseUrl } from "./config"
import { TABLE_DEPENDENCY_ORDER } from "./tableOrder"

export interface SetupTestDataEntry<T extends keyof DB> {
  table: T
  rows: Insertable<DB[T]>[]
}

export type SetupTestData = SetupTestDataEntry<keyof DB>[]

export class MainDb {
  async dataSetList(): Promise<SetupTestData[]> {
    return [
      setupAuthLogoutPost(),
      setupSampleGet(),
      // ...必要に応じて別の setup 関数をここに追加...
    ]
  }

  getConnection() {
    return new Kysely<DB>({
      dialect: new PostgresDialect({
        pool: new Pool({ connectionString: databaseUrl })
      })
    })
  }

  async setup(): Promise<void> {
    const db = this.getConnection()
    const flatten = (await this.dataSetList()).flat()

    for (const tableName of TABLE_DEPENDENCY_ORDER) {
      const filteredTableData = flatten.filter((dataSet) => dataSet.table === tableName)
      for (const tableWithData of filteredTableData) {
        await db.insertInto(tableName).values(tableWithData.rows).execute()
      }
    }
    await db.destroy()
  }

  async clear(): Promise<void> {
    const db = this.getConnection()
    const reversedTables = [...TABLE_DEPENDENCY_ORDER].reverse()
    for (const t of reversedTables) {
      await db.deleteFrom(t).execute()
    }
    await db.destroy()
  }
}
  • dataSetList()setup/database/ 以下の関数を配列で返す構造。テストケースごとのデータが疎結合で管理できる
  • clear()TABLE_DEPENDENCY_ORDER を逆順にたどって delete を実行。外部キー制約違反を確実に防ぐ

セットアップ関数の中身は以下のように、ドメインごとに必要なテーブルへ SetupTestData を返すだけです。

// test/api/setup/database/setupSampleGet.ts
import type { SetupTestData } from "~/scripts/database"
import { createSetupAccountData } from "~/setup/helper/createSetupAccountData"

export const setupSampleGet = (): SetupTestData => {
  const accountId = "019a182e-8911-7291-8632-02012f2f2dac"
  const { accounts } = createSetupAccountData({
    accountId,
    email: "sample_get@example.com",
    name: "SampleGet"
  })

  return [
    { table: "accounts", rows: accounts },
    {
      table: "sample_table",
      rows: [
        {
          id: "019a182e-d4dd-73e3-b89c-d238d8dc2fe6",
          // ...
        }
      ]
    }
  ]
}

3. テーブル作成順を generateTableOrder.ts で自動生成

新しいテーブルや外部キーを追加したときは、test/api/scripts/generateTableOrder.ts を一度実行します。

pnpm run generate-table-order

このスクリプトは pg_constraint を参照して依存グラフをつくり、DFS ベースのトポロジカルソートで TABLE_DEPENDENCY_ORDER を生成します。全文を載せておきます。

// test/api/scripts/generateTableOrder.ts
import { mkdirSync, writeFileSync } from "node:fs"
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import { Client } from "pg"

const __dirname = dirname(fileURLToPath(import.meta.url))

async function generateOrder() {
  const client = new Client({
    connectionString: process.env.DATABASE_URL || "postgresql://postgres:postgres@localhost:15432/main"
  })
  await client.connect()
  const { rows } = await client.query(`
    WITH RECURSIVE deps AS (
      SELECT conrelid::regclass AS child, confrelid::regclass AS parent
      FROM pg_constraint WHERE contype='f'
    )
    SELECT DISTINCT parent::text AS parent_table, child::text AS child_table
    FROM deps;
  `)

  const adj = new Map<string, string[]>()
  const nodes = new Set<string>()
  for (const { parent_table, child_table } of rows) {
    nodes.add(parent_table)
    nodes.add(child_table)
    adj.get(parent_table)?.push(child_table) || adj.set(parent_table, [child_table])
    if (!adj.has(child_table)) {
      adj.set(child_table, [])
    }
  }

  const visited = new Set<string>()
  const onStack = new Set<string>()
  const result: string[] = []
  const dfs = (u: string) => {
    if (onStack.has(u)) {
      throw new Error(`依存サイクル検出: ${u}`)
    }
    if (visited.has(u)) {
      return
    }
    visited.add(u)
    onStack.add(u)
    for (const v of adj.get(u) || []) {
      dfs(v)
    }
    onStack.delete(u)
    result.push(u)
  }
  for (const u of nodes) {
    dfs(u)
  }

  const creationOrder = [...result].reverse()
  const outPath = resolve(__dirname, "../scripts/tableOrder.ts")
  mkdirSync(dirname(outPath), { recursive: true })
  const content = `// AUTO-GENERATED by generateTableOrder.ts\nexport const TABLE_DEPENDENCY_ORDER = ${JSON.stringify(creationOrder, null, 2)} as const\n`
  writeFileSync(outPath, content, "utf8")
  await client.end()
}

generateOrder().catch(console.error)

出力は scripts/tableOrder.ts に保存され、Playwright のセットアップや scripts/database.ts からそのまま再利用できます。人間が並べ替えないので、外部キーの追加漏れによるテスト落ちを防げます。

4. フィクスチャーデータの管理

setup/database/ 配下はエンドポイント単位のフィクスチャを返す純関数で構成しています。

  • setupSampleGet() のような共通セットアップは常に先頭で実行
  • すべて SetupTestData 型(tablerows のペア)で返すため、データの整合性を TypeScript 上で保証

このディレクトリには helper/ などドメイン別の補助関数も置いており、シナリオごとのデータ量を必要最小限に保っています。

5. 実行手順(再現メモ)

  1. 依存インストール: pnpm install
  2. DB 起動: docker compose -f docker-compose.yml up -d db api
  3. テーブル順更新(必要なときだけ): pnpm run generate-table-order
  4. Playwright 実行: pnpm run testglobalSetup が自動で DB を初期化してくれる

CI でも同じ順序を踏めば、テスト間で状態が共有されることはありません

まとめ

  • Postgres は Docker Compose(docker-compose.yml)で即座に立ち上げる
  • データ投入と削除は scripts/database.tsMainDb で一元化
  • テーブル依存の解決は scripts/generateTableOrder.ts に任せてヒューマンエラーを排除

インテグレーションテストの辛い部分(DB リセットとフィクスチャ管理)を仕組み化すると、Playwright から HTTP 契約だけに集中できます。新しいテーブルや API が増えたら、スクリプトを再実行して tableOrder.ts を更新することだけ忘れなければ OK です。