「npm run e2e」を中央に据え、Supabase起動、DBリセット、認証モック、Cloudflareを経由し、テスト実行とクリーンアップまでを網羅するローカルE2E環境の自動化ワークフロー図。チェッカーフラッグを持つキャラクターがDXの向上を示唆します。

概要

ローカル E2E のたびに「Supabase 起動 → DB リセット → 認証モック起動 → .dev.vars 編集 → ようやく npm run e2e」を踏んでいると、心理的なハードルで E2E 自体が回らなくなります。npm run e2e を打つだけで前処理が完結し、終了時にきれいに片付く構成にすると、PR を出すたびにふつうに E2E が走るようになります。

本記事は Cloudflare Workers + Supabase(Postgres)+ Playwright の構成を前提に、ローカル E2E を「自己完結スクリプト」に組み立てる実装パターンです。Auth モック、一時環境ファイルの安全なクリーンアップ、状態解決のフォールバック、の3点を扱います。

全体構成

npm run e2e
  └─> scripts/e2e.mjs
        ├─> prepareLocalE2EEnvironment()      ← 前処理
        │     ├─ ensureLocalDatabaseUrl       (Supabase の DB_URL を解決、未起動なら start)
        │     ├─ resetLocalDatabase           (supabase db reset で初期化)
        │     ├─ startSupabaseAuthMock        (小さな HTTP サーバで認証API代替)
        │     └─ stageEnvironmentFiles        (一時 .dev.vars / .env.local を生成)
        ├─> wrangler dev + Playwright run
        └─> finally cleanup()                 ← 失敗しても必ず後始末

エントリの責任ある cleanup パターン

非同期リソースを確保する関数は、途中で失敗したら確保済みのリソースを必ず解放します。確保順の逆順で cleanup を呼びます。

export async function prepareLocalE2EEnvironment(repoRoot) {
  const dbUrl = await ensureLocalDatabaseUrl(repoRoot)
  await resetLocalDatabase(repoRoot)
  const authMock = await startSupabaseAuthMock(dbUrl)

  const runtimeEnv = {
    CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE: dbUrl,
    SUPABASE_URL: authMock.baseUrl,
    SUPABASE_ANON_KEY: 'e2e-anon-key',
    // ...
  }

  let envFiles
  try {
    envFiles = await stageEnvironmentFiles(repoRoot, runtimeEnv)
  } catch (error) {
    await authMock.cleanup()  // ★ envFiles 失敗時は authMock を片付ける
    throw error
  }

  return {
    runtimeEnv,
    cleanup: async () => {
      await envFiles.cleanup()  // ★ 確保順の逆で片付け
      await authMock.cleanup()
    },
  }
}

ポイント:

  • cleanup は 1 つのリソース専用にして、再入可能(idempotent)にしておきます
  • 呼び出し側は try { ... } finally { await env.cleanup() } で必ず後片付けをします

状態解決のフォールバック

「Supabase が起動済みなら DB_URL を取得、未起動なら起動してから取得」をリトライ無しで素直に書きます。

async function ensureLocalDatabaseUrl(repoRoot) {
  // 環境変数で明示されていれば最優先
  const explicit = process.env.CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE?.trim()
  if (explicit) return explicit

  // 1回目: 既に起動していれば取れる
  const first = await readSupabaseStatusEnv(repoRoot)
  if (first.DB_URL) return first.DB_URL

  // 起動して 2回目: 起動成功後に再取得
  await runSupabaseCommand(repoRoot, ['start', '--ignore-health-check'])
  const second = await readSupabaseStatusEnv(repoRoot)
  if (second.DB_URL) return second.DB_URL

  throw new Error('Unable to resolve a local DB_URL.')
}

supabase status -o envDB_URL=... 形式の env 出力をくれるのを利用します。リトライループを書くのではなく「未起動なら start して再取得」の2段で十分です。

認証 API モック: pg 直接アクセスの小さな HTTP サーバ

Supabase Auth API の本物を再現する必要はありません。テストで使う最小操作(signUp / signIn / getUser)だけ、Postgres に直接書く HTTP サーバで代替します。

async function startSupabaseAuthMock(dbUrl) {
  const pool = new Pool({ connectionString: dbUrl, max: 1 })
  const usersByEmail = new Map()
  const usersById = new Map()

  // demo ユーザを seed
  await pool.query(`INSERT INTO public.users (id, email) VALUES ($1, $2)
                    ON CONFLICT (id) DO NOTHING`, [DEMO_USER_ID, DEMO_EMAIL])

  const server = createServer((req, res) => {
    // /auth/v1/token, /auth/v1/user, /auth/v1/signup の最小実装
    // ...
  })
  await new Promise((resolve) => server.listen(0, resolve))
  const baseUrl = `http://127.0.0.1:${server.address().port}`

  return {
    baseUrl,
    cleanup: async () => {
      await new Promise((resolve) => server.close(resolve))
      await pool.end()
    },
  }
}

利点:

  • 認証ロジックの揺れに振り回されません(トークン検証等は不要、E2E 用の固定値で OK)
  • 起動が速いです(コンテナ起動を待ちません)
  • ポートは listen(0) でOSに割り当てさせ、衝突を避けられます

「特定操作を1回だけ許可する」をDB制約で表現する

E2E に絡む副次的な学びです。「祝福通知は同じ目標で1回だけ」のような操作の唯一性は、アプリ層の isAlreadyCelebrated フラグより、DB の PRIMARY KEY 制約で表現する方が堅いです。

CREATE TABLE IF NOT EXISTS goal_completion_events (
  goal_id INTEGER PRIMARY KEY REFERENCES goals(id) ON DELETE CASCADE,
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  completed_at TEXT NOT NULL DEFAULT (datetime('now'))
);

goal_id を PRIMARY KEY にすることで、同じ goal を何度達成しても 2 回目以降は INSERT が失敗します。アプリは INSERT ... ON CONFLICT DO NOTHING で受け止めればよく、競合状態の心配がなくなります。

まとめ

  • ローカル E2E は npm run e2e 一発で完結 を目指します。前処理ドキュメントを書くより遥かに実用的です
  • 確保したリソースは 逆順 cleanup、途中失敗時も既確保ぶんを片付けます
  • 状態解決は「既に整っているなら取得、無ければ作る」の2段フォールバックで素直に実現できます
  • 認証モックは pg 直接アクセスの小さな HTTP サーバで充分です
  • 「特定操作を1回だけ」は DB の PRIMARY KEY で表現するのが堅いです

次にやりたいこと

  • E2E のフレーク率モニタリング(テスト名 × 失敗率の推移)
  • Auth モックを共通パッケージ化して別プロジェクトで再利用
  • e2e-local-environment.mjs を CI でも使える形に拡張(GitHub Actions 上で Supabase を立ち上げる版)