Portfolio logo
🧪

Cloudflare D1 + Kysely で API の E2E テストを組んだ構成メモ

Cloudflare D1 と Kysely を使う API で、Playwright による E2E テストを実際にどう組んだかをまとめた構成メモ。migration、seed、Wrangler の起動衝突、pnpm run dev 起動中の対策まで整理する。

著者 Ryo Full-stack engineer
#cloudflare #d1 #kysely #playwright #api #e2e

Cloudflare D1 と Kysely を組み合わせた API の E2E テストは、最初の一歩は簡単でも、運用を安定させようとすると急に難しくなります。

特に厄介なのは、以下のような論点です。

  • D1 の migration をどこでどう実行するか
  • Playwright の globalSetupwrangler dev をどう組み合わせるか
  • seed データをどう管理するか
  • すでに pnpm run dev で API が起動している状態でも E2E を実行できるようにするにはどうすればいいか

この記事では、実際に monorepo で整理した構成をベースに、Cloudflare D1 + Kysely + Playwright の API E2E テストをこの構成でどう組んだかをまとめます。

結論

先に全体像を書くと、この構成ではこうしています。

  • D1 migration は Kysely の TypeScript migration ではなく raw SQL に寄せる
  • Playwright の webServer には寄せず、globalSetup / globalTeardown で Wrangler の起動と停止を制御する
  • seed は db-fixtures.ts に全部直書きせず、共通 seed pipeline + 別ファイルの bundle に分ける
  • E2E は通常の pnpm run dev別 port / 別 persistence を使う
  • wrangler d1 migrations apply --persist-togetPlatformProxy()同じ persistence を見るように揃える

結果として、pnpm run dev が動いていても止まっていても、pnpm run test:e2e を独立して実行できる形になりました。

前提のディレクトリ構成

今回の構成は、monorepo の API 配下で次のように整理しています。

services/api/
├── src/
│   └── db/
│       └── migrations/
│           └── 20260411_0001_initial.sql
├── scripts/
│   ├── e2e-db-setup.ts
│   ├── local-d1.ts
│   └── local-platform.ts
├── tests/
│   └── e2e/
│       ├── db-fixtures.ts
│       ├── extra-seed-data.ts
│       ├── global-setup.ts
│       ├── global-teardown.ts
│       ├── runtime.ts
│       ├── seed-data.ts
│       └── TEST_RULES.md
├── playwright.config.ts
├── package.json
└── wrangler.toml

この構成では、D1 の初期化・seed・Wrangler 起動の責務を分けるようにしています。

1. なぜ Kysely migration ではなく raw SQL に寄せたのか

Kysely は普段の query builder としてはかなり使いやすいです。ただ、Cloudflare D1 の local 環境と migration runner まで含めて考えたとき、この構成では Kysely の TypeScript migration を主軸にはしませんでした。

本当は最初、Kysely の migration でやりたかったです。Kysely の docs にある migration の形はかなり素直で、普段の TypeScript 開発フローにも馴染みます。

import { Kysely } from "kysely"

export async function up(db: Kysely<any>): Promise<void> {
  // migration code
}

export async function down(db: Kysely<any>): Promise<void> {
  // migration code
}

Kysely の migration docs: https://kysely.dev/docs/migrations

理由は単純で、最終的に D1 が読むのは SQL だからです。

それに加えて、今回のデータベース自体が Cloudflare D1 に依存しています。つまり migration の実行主体も Cloudflare 側に寄せたほうが、local 実行でも E2E でも頭の中のモデルをそろえやすかったです。

この構成でやりたかったのは、Kysely migration runner を別に持つことではなく、Cloudflare D1 の local DB に対して migration して、その state をそのまま Playwright E2E で使うことでした。そう考えると、wrangler d1 migrations apply を中心にしたほうが流れが自然でした。

もうひとつ、Kysely migration の down も今回の文脈では少し冗長に感じました。

  • migration の例としては up / down の両方を書く
  • ただし E2E では migration を戻すのではなく、local persistence を削除して最初から作り直す
  • つまり毎回やっているのは down ではなく「state の破棄 + up の再適用」

この運用だと、down をちゃんと書いても E2E ではほとんど使いません。もちろん migration としては正しい形なのですが、今回の用途では書き方だけが少し重くなる印象がありました。

今回は、初期化の入口を Cloudflare 標準に寄せたかったので、migration は次のような raw SQL ファイルにしました。

CREATE TABLE users (
  user_id TEXT PRIMARY KEY,
  email TEXT NOT NULL,
  refresh_token TEXT,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

そして適用は wrangler d1 migrations apply に一本化します。

{
  "scripts": {
    "db:migrate:e2e:local": "yes Y | wrangler d1 migrations apply app_db --local --env e2e --persist-to .wrangler/e2e-state"
  }
}

こうしておくと、本番に近いコマンドで local D1 を毎回再現できるのがよかったです。

2. 最初にぶつかった問題

最初はもっと素朴に組んでいました。

  • Playwright の globalSetup から wrangler を直接 import する
  • playwright.config.ts では webServer を使う
  • local D1 の state は .wrangler/state を共有する

この構成で、実際に次のような問題が起きました。

2.1 Playwright のローダと wrangler import の相性問題

globalSetup から getPlatformProxy() を使おうとすると、ESM/CJS の境界で不安定になりやすく、モジュール初期化エラーが出ました。

テストランナーのローダ都合に DB 初期化を巻き込むのは、かなり脆いです。

2.2 webServer 先行起動と D1 state の不整合

webServer で先に wrangler dev が起動した後に globalSetup 側で D1 state を作り直すと、起動済みサーバが見ている DB と、seed を流し込んだ DB がズレることがあります。

この問題は見つけにくいです。migration 自体は成功しているのに、アプリから見ると「テーブルがない」「seed が入っていない」ように見えます。

2.3 pnpm run dev 起動中の衝突

通常の開発サーバが 8787.wrangler/state を使って起動しているとき、E2E も同じ port と state を使うと次のどちらかで壊れます。

  • ポート競合で Wrangler が起動できない
  • 同じ persistence を見てしまい、通常開発と E2E が同じ D1/R2 state を触ってしまう

3. この構成で採用した形

最終的には、Playwright の lifecycle は使いつつ、責務はこう分けました。

3.1 Setup は script に寄せる

globalSetup から直接 DB ロジックを抱え込まず、外部 script を叩く形にしています。

// scripts/e2e-db-setup.ts
import { prepareE2EDatabase } from "../tests/e2e/db-fixtures"

async function main() {
  await prepareE2EDatabase()
}

main().catch((error) => {
  console.error(error)
  process.exit(1)
})

この prepareE2EDatabase() の中では、実際には次の処理をまとめてやっています。

// tests/e2e/db-fixtures.ts
export async function prepareE2EDatabase() {
  resetLocalD1Database()

  const seedBundle = mergeSeedBundles(
    { seedData: e2eSeedData, assets: [] },
    extraSeedBundle
  )

  await executeLocalD1Seed(seedBundle.seedData)
  await seedLocalAssets(seedBundle.assets ?? [])
}

function resetLocalD1Database() {
  rmSync(LOCAL_E2E_STATE_DIR, { force: true, recursive: true })

  execFileSync("pnpm", ["run", "db:migrate:e2e:local"], {
    stdio: "inherit"
  })
}

つまり prepareE2EDatabase() は、ただ seed を入れているだけではありません。

  • E2E 用の local persistence を削除する
  • raw SQL migration を流してテーブルを作り直す
  • テスト用の D1 データを投入する
  • 必要なら R2 の画像データも投入する

import { prepareE2EDatabase } ... だけだと何をしているか見えにくいのですが、実態としては E2E 環境を毎回ゼロから組み直す入口 です。

3.2 Playwright は Wrangler の起動だけを担当する

globalSetup では、

  1. E2E DB を初期化
  2. wrangler dev を別プロセスで起動
  3. /ping で readiness を待つ

という順序にします。

// tests/e2e/global-setup.ts
execFileSync("pnpm", ["run", "e2e:db:setup"], {
  cwd: apiRootDirectory,
  stdio: "inherit"
})

const wranglerProcess = spawn(
  "pnpm",
  [
    "wrangler",
    "dev",
    "src/index.ts",
    "--env",
    "e2e",
    "--port",
    e2ePort.toString(),
    "--persist-to",
    e2eStateDirectory
  ],
  {
    cwd: apiRootDirectory,
    detached: true,
    stdio: "inherit"
  }
)

この順番にすることで、seed が入った DB を見ながら API が起動する状態にしています。

4. pnpm run dev 起動中でも成功させるための分離

今回いちばん大事だったのはここです。

今回の要件のひとつが、通常の pnpm run dev が起動しているときでも E2E を実行できることでした。そのため、E2E は通常開発環境と完全に分離しています。

具体的には次の 2 つです。

  • port を分ける
  • Wrangler persistence を分ける

4.1 port を分ける

通常の dev8787 を使います。E2E は 8788 に固定しました。

// scripts/local-platform.ts
export const e2ePort = 8788
// tests/e2e/runtime.ts
export const e2eBaseUrl = `http://127.0.0.1:${e2ePort}`
// playwright.config.ts
use: {
  baseURL: e2eBaseUrl,
}

これで、起動済み API との port 競合を避けています。

4.2 persistence を分ける

本質はむしろこっちです。

通常の dev.wrangler/state を見ているなら、E2E は .wrangler/e2e-state を使います。

// scripts/local-platform.ts
export const e2ePersistRootDirectory = resolve(apiRootDirectory, ".wrangler/e2e-state")
export const e2ePlatformPersistDirectory = resolve(e2ePersistRootDirectory, "v3")

ここで重要だったのは、migration コマンドと script 側の getPlatformProxy() が同じ state を見ることです。

{
  "scripts": {
    "db:migrate:e2e:local": "yes Y | wrangler d1 migrations apply app_db --local --env e2e --persist-to .wrangler/e2e-state"
  }
}
// scripts/local-platform.ts
const platform = await getPlatformProxy({
  configPath: wranglerConfigPath,
  environment: "e2e",
  persist: {
    path: e2ePlatformPersistDirectory
  }
})

ここは、単に「同じようなパスを設定する」という意味ではありません。migration で作った SQLite ファイルを、seed script 側もまったく同じ場所で開けるようにするという意味です。

今回の構成では、wrangler d1 migrations apply --persist-to .wrangler/e2e-state を実行すると、実際の local D1 は .wrangler/e2e-state/v3/d1 配下に作られます。

一方で getPlatformProxy() 側は persist.path に渡した場所を基準に local state を開きます。ここで migration と script 側の参照先がずれていると、次のようなことが起きます。

  • migration は成功している
  • seed script も動いているように見える
  • でも script 側は migration 済みではない別の local DB を見ている
  • 結果として no such table: users のようなエラーになる

実際にハマったのもこれでした。

  • wrangler d1 migrations apply --persist-to .wrangler/e2e-state
  • wrangler dev --persist-to .wrangler/e2e-state
  • getPlatformProxy({ persist: { path: ".wrangler/e2e-state" } })

一見そろっているように見えますが、これでは script 側が migration 済み DB と別の場所を見てしまいました。そこで getPlatformProxy() 側だけは v3 まで含めたパスを渡す形にしています。

export const e2ePersistRootDirectory = resolve(apiRootDirectory, ".wrangler/e2e-state")
export const e2ePlatformPersistDirectory = resolve(e2ePersistRootDirectory, "v3")
persist: {
  path: e2ePlatformPersistDirectory
}

この差が分かっていないと、migration は通るし wrangler dev も起動するのに、seed 時だけ「テーブルがない」というかなり分かりづらい壊れ方になります。

5. seed は共通処理 + 別ファイルの bundle に分ける

E2E の seed は、最初は db-fixtures.ts に全部書きたくなります。ですが、それだとデータが増えた瞬間に読めなくなります。

そこで、この構成では次のように分けました。

tests/e2e/
├── db-fixtures.ts
├── seed-data.ts
└── extra-seed-data.ts
  • seed-data.ts: 共通の seed 型、merge、SQL statement 化
  • db-fixtures.ts: E2E 全体の orchestration
  • extra-seed-data.ts: 追加の表示確認用 seed bundle

db-fixtures.ts は、共通 seed pipeline の組み立てだけをします。

const seedBundle = mergeSeedBundles(
  { seedData: e2eSeedData, assets: [] },
  extraSeedBundle
)

await executeLocalD1Seed(seedBundle.seedData)
await seedLocalAssets(seedBundle.assets ?? [])

この形にすると、E2E の seed 処理は共通のまま、用途別データだけ別ファイルに逃がせるので、運用しやすくなりました。

6. 最終的にこの構成でこうなった

最終的には、次の形に落ち着きました。

  • migration は raw SQL にする
  • E2E 用の D1/R2 state は通常開発と分離する
  • Playwright は globalSetup / globalTeardown で Wrangler を管理する
  • seed は共通 pipeline で流し、データ本体は別ファイルに切り出す
  • pnpm run test:e2e は、通常の pnpm run dev が起動中でも成功することを前提に設計する

この形にしてから、少なくとも「今 API が起動していたせいで落ちた」「migration は通ったのに seed が見えない」といった種類の事故はかなり減りました。

まとめ

今回やりたかったのは、Cloudflare D1 を使う API に対して、Playwright で E2E を回しつつ、すでに pnpm run dev が起動している状況でもテストを独立して成立させることでした。

そのためにこの構成では、

  • migration は wrangler d1 migrations apply に寄せる
  • globalSetup で E2E 用 DB を作り直してから wrangler dev を起動する
  • E2E は通常開発環境と別の port / persistence を使う
  • seed script と Wrangler が同じ local state を見るようにそろえる

という形にしています。

結局いちばん重要だったのは、Kysely の migration をどう書くかよりも、migration・seed・wrangler dev・Playwright が同じ local state を見るようにそろえることでした。

その前提がそろっていれば、pnpm run dev が動いているかどうかを気にせず pnpm run test:e2e を実行できます。今回の構成は、そのための整理でした。