Summary

前回の記事で「npm run e2e 一発で完結するローカルE2E環境」を作りました。その後しばらく運用したところ、ある日から E2E が毎回失敗するようになり、しかも 1 回 65 秒かかる状態に陥っていました。

原因は 2 つ。

  1. Supabase CLI がバンドルする Docker イメージのスキーマと、本番マイグレーション末尾で参照している storage.prefixes テーブルの差分により、マイグレーションが必ず途中で relation does not exist で止まるようになっていた
  2. E2E のたびに supabase db reset でコンテナ再起動を含む**フルリセット(約 30 秒)**を毎回走らせていた

対策として、既存マイグレーションを to_regclass() ガード付き DO ブロックに書き換えて環境差を吸収し、さらに マイグレーション内容の sha256 キャッシュ + 一致時は TRUNCATE のみで済ませる高速パス + DBリセットとauthモック起動の並列化 を入れました。結果、反復実行時は 65s → 15-18s(-72%)、フルリセット時でも 42s(-35%) に短縮できました。

こんな人におすすめ

  • ローカル E2E が遅すぎて開発の流れを止めている方
  • Supabase CLI のローカル環境と本番でマイグレーションの挙動がズレて困っている方
  • supabase db reset を毎回呼ぶのは重いけど、テスト間の DB 状態は確実にクリーンにしたい」というジレンマを抱えている方
  • Playwright の fullyParallel: true を活かすための前処理設計を考えている方

問題1:CLI バンドル Docker と本番の storage スキーマ差

E2E が壊れた直接の原因は、本番マイグレーション末尾の以下のような SQL でした。

drop trigger if exists "objects_delete_delete_prefix" on "storage"."objects";
drop trigger if exists "prefixes_create_hierarchy" on "storage"."prefixes";

本番の Supabase は storage.prefixes テーブルを持っていますが、ローカル CLI がバンドルしている Docker イメージ(古いバージョン)には storage.prefixes が存在しません。DROP TRIGGER ... ON storage.prefixesテーブルが存在しないと relation does not exist で失敗するため、supabase db reset が必ずここで止まります。

「新しいマイグレーションを後から追加して修正」も無理です。Supabase CLI は古いマイグレーションから時系列順に適用していくので、問題のあるマイグレーションに到達した時点で止まり、後続のファイルは実行されません

仕方がないので、既存マイグレーションファイルを to_regclass() ガード付き DO ブロックに直接書き換えました。

-- 環境によって storage.prefixes が存在しないため、
-- テーブル存在確認を to_regclass() で行ってから DROP する
do $$
begin
  if to_regclass('"storage"."objects"') is not null then
    execute 'drop trigger if exists "objects_delete_delete_prefix" on "storage"."objects"';
    execute 'drop trigger if exists "objects_insert_create_prefix" on "storage"."objects"';
    execute 'drop trigger if exists "objects_update_create_prefix" on "storage"."objects"';
  end if;
  if to_regclass('"storage"."prefixes"') is not null then
    execute 'drop trigger if exists "prefixes_create_hierarchy" on "storage"."prefixes"';
    execute 'drop trigger if exists "prefixes_delete_hierarchy" on "storage"."prefixes"';
  end if;
end $$;

DROP TABLE IF EXISTS のテーブル版として to_regclass() は便利です。引数には完全修飾形式('"schema"."table"')で渡す必要があります。

既存マイグレーションを書き換える"抜け穴"

ここで気になるのが、既存マイグレーションを後から書き換えていいのか? という点。Supabase CLI のバージョン管理はファイル名(タイムスタンプ)のみを見ていてチェックサム比較をしないため、本文を書き換えても本番で再適用は走りません。

ただしこれは設計上の"抜け穴"であり、本来やってはいけないやり方です。コミットメッセージにも「Supabase CLI を v2.95.4+ に揃えたら元の形に戻す」と暫定対処であることを明記しました。技術的負債として追跡するのが前提です。

問題2:毎回フルリセットで 65 秒

もう 1 つの問題は、E2E 実行のたびにコンテナ再起動を含むフルリセットを呼んでいたこと。supabase db reset --local は約 20 秒、その後の認証モックや Playwright 起動を含めると 1 回 65 秒かかります。これは反復回数が増えるほど致命的です。

ただし、テスト間の DB 状態を確実にクリーンにする責務は手放したくありません。そこでマイグレーションの内容が前回と同じなら TRUNCATE のみで済ませるキャッシュ戦略を入れました。

マイグレーションハッシュを使ったリセットキャッシュ

async function resetLocalDatabase(repoRoot, dbUrl) {
  const cachePath = path.join(repoRoot, 'tmp', 'e2e', '.last-reset-hash')
  const currentHash = await computeMigrationsHash(repoRoot)
  const skipCache = process.env.E2E_SKIP_RESET_CACHE === '1'
  const cachedHash = skipCache ? null : ((await readOptionalFile(cachePath))?.content.trim() ?? null)

  if (cachedHash === currentHash) {
    try {
      await truncateUserState(dbUrl)
      return  // 高速パス: 1〜2 秒
    } catch (error) {
      // truncate 失敗時はフルリセットに fallback
    }
  }

  await runSupabaseCommand(repoRoot, ['db', 'reset', '--local', '--yes', '--no-seed'], { ... })
  await writeFile(cachePath, currentHash, 'utf8')
}

async function computeMigrationsHash(repoRoot) {
  const dir = path.join(repoRoot, 'supabase', 'migrations')
  const entries = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort()
  const contents = await Promise.all(entries.map((name) => readFile(path.join(dir, name))))
  const hash = createHash('sha256')
  for (let i = 0; i < entries.length; i++) {
    hash.update(entries[i])
    hash.update('\0')
    hash.update(contents[i])
    hash.update('\0')
  }
  return hash.digest('hex')
}

ファイル名と内容を交互に \0 区切りでハッシュに混ぜているのがポイントです。「同名ファイルの内容入れ替わり」も「ファイル名変更なしの内容変更」も両方検出できます。区切り文字なしで連結すると、ファイル境界が曖昧になり別パターンと衝突する可能性があります。

E2E_SKIP_RESET_CACHE=1 でいつでもキャッシュをバイパスできるようにしてあります。CI ではこちらを必ず有効にすると安心です。

TRUNCATE 失敗時はフルリセットに fallback

TRUNCATE ... RESTART IDENTITY CASCADE は、外部キー制約の都合や保護対象テーブルの定義変更で失敗することがあります。失敗時は黙ってフルリセットに切り替える二段構えにしました。TRUNCATE の失敗は致命的ではなく「キャッシュが古くなっただけ」と扱えるからです。

問題3:reset と auth モック起動が直列だった

ここまでで反復時の高速パスは確保できましたが、初回や CI のフルリセット時は依然として遅いままでした。プロファイリングしたところ、DB リセット(30 秒)と auth モック起動(4 秒)が直列に実行されているのが分かりました。

auth モックは小さな HTTP サーバですが、起動時にデモユーザを DB にも書き込んでいたため、DB リセット完了を待つしかなかったのです。

そこで、auth モックの**「Map 登録」と「DB 書き込み」を分離**し、DB 書き込みを呼び出し元が制御できる関数として返すようにしました。

// startSupabaseAuthMock 内: Map 登録は即時、DB 書き込みは関数として遅延
const seedDatabase = () =>
  replaceDatabaseUser(pool, { id: DEMO_USER_ID, email: DEMO_EMAIL, ... })

return {
  baseUrl: `http://127.0.0.1:${port}`,
  seedDatabase,        // 呼び出し元が制御するタイミングで実行
  cleanup: async () => { ... },
}

// prepareLocalE2EEnvironment 内: reset と auth モックを並列化
const resetPromise = resetLocalDatabase(repoRoot, dbUrl)       // ~30s
const authMock = await startSupabaseAuthMock(dbUrl)            // ~4s (resetPromise と並走)

// 両方完了後に DB 書き込みを行う ready Promise
const ready = resetPromise.then(() => authMock.seedDatabase())

return { runtimeEnv, ready, cleanup }

// e2e.mjs 内: Playwright 起動前に必ず await
if (localEnvironment?.ready) {
  await localEnvironment.ready
}

ready を返すだけで await を呼び出し元任せにする設計には危うさがあります。呼び出し元が忘れると DB 未準備のまま Playwright が動いてしまうからです。コードコメントに 「Playwright 起動前に必ず await する必要がある」 と明記しておくのを強くおすすめします。

結果:65s → 15-18s

実行パターン Before After 削減率
反復実行(マイグレーション変更なし) 65s 15-18s -72%
フルリセット(マイグレーション変更後・初回) 65s 42s -35%

Playwright の fullyParallel: true + workers: 4 がもともと有効だったことも効きました。各テストが独立したメールアドレスでユーザを作成する設計だったため、worker 間でユーザ衝突が起きません。「テストが暗黙のグローバル状態に依存しない」状態が保たれていたことが、性能改善の前提になっていました。

E2E の高速化はトリッキーなテクニック以前に、テスト設計が並列実行に耐える形になっているかを点検することが本質だと改めて思いました。

振り返り:環境差の吸収パターン3つ

今回の作業から、ローカル CLI と本番の環境差を扱う場面で使えるパターンが整理できました。

  1. to_regclass() ガード:テーブル存在で分岐する DDL に。DROP TABLE IF EXISTS のテーブル版として汎用的に使えます
  2. マイグレーション内容ハッシュsupabase db reset のような重い処理をスキップする判定キーとして。ファイル名 + 内容を \0 区切りで連結すれば衝突を避けられます
  3. 「即時登録」と「DB 書き込み」の分離:起動コストが高い処理(DB リセット)と低い処理(インメモリ Map 登録)を並列化する余地を作る設計

特に 2 つ目は SaaS の seed データ管理や CI 全般に応用が効きます。「重い処理を毎回走らせるのではなく、入力が変わったときだけ走らせる」という発想は、開発体験の設計でもっと活用できる気がしました。

残課題

  • Supabase CLI v2.95.4+ への追従storage.prefixes ガードを撤回して元のシンプルなマイグレーションに戻すための前提
  • キャッシュファイルのクリーンアップtmp/e2e/.last-reset-hash は手動削除前提。古い世代が溜まる前に TTL を入れるか、CI でクリーンするか
  • 保護対象テーブルの自動検出MASTER_TABLES_TO_PRESERVE / DEMO_SEEDED_TABLES を手で管理しているため、テーブル追加時の更新漏れが潜在負債

まとめ

npm run e2e 一発で完結する」を実現したあと、それを継続的に維持するには別レイヤーの工夫が必要でした。今回はマイグレーション環境差・反復時のリセット重さ・初回の直列待ちという 3 つの課題に対し、to_regclass() ガード・ハッシュキャッシュ・並列化で対応しました。

E2E はテストコード自体より「前処理の設計」のほうが寿命に効きます。動かしている方の参考になれば幸いです。