tech

Drizzle + Vitest でトランザクションを使うテストの分離方法

はじめに

Drizzleを使って新しいアプリのAPIを作っていて、テストを書く時にtransaction周りの処理が上手くいかなかったのでメモです。

テストのデータ分離には、「外側で BEGIN を張って、各テストは SAVEPOINT に戻す」方式を採用しようとしていました。

beforeAll(async () => {
  await testDb.execute(sql`BEGIN`)
})

beforeEach(async () => {
  await testDb.execute(sql.raw(`SAVEPOINT sp_${counter}`))
})

afterEach(async () => {
  await testDb.execute(sql.raw(`ROLLBACK TO SAVEPOINT sp_${counter}`))
})

afterAll(async () => {
  await testDb.execute(sql`ROLLBACK`)
})

この方式自体は最初のシンプルな実装では問題なく動いていたのですが、アプリコード内で db.transaction() を使うエンドポイントのテストを書き始めたところ、以下のエラーで落ちるようになりました。

ROLLBACK TO SAVEPOINT can only be used in transaction blocks

GET/DELETE など db.transaction() を使わないエンドポイントは通るのに、POST/PUT など db.transaction() を使うエンドポイントだけ落ちる、という状況でした。

原因

原因は「テストが beforeAll で張った外側のトランザクションを、アプリの db.transaction()COMMIT で閉じてしまう」ことでした。

流れにするとこんな感じです。

  1. テストが BEGIN(外側トランザクション開始)
  2. アプリコードが db.transaction() を実行(内部で BEGIN → 処理 → COMMIT
  3. アプリが db.transaction() 完了によって COMMIT が発行されると、外側のトランザクションも一緒に終了してしまう
  4. テストの afterEachROLLBACK TO SAVEPOINT ... を実行
  5. すでにトランザクション外になっているため失敗してしまう

PostgreSQL(と主要なRDBMS)ではトランザクション内で BEGIN しても新しいネストは作られません。ネストさせたい場合は ORM 側が SAVEPOINT に動的に変更する必要がありますが、Drizzle の transaction() は自動でやってくれるわけではないため、衝突しました。

関連する議論:

対策案の比較

論点は「テスト分離のための外側 BEGIN/SAVEPOINT」と「アプリの db.transaction()」をどう共存させるか、です。

案A: テスト環境で db.transaction() を SAVEPOINT 実装に差し替える

  • 長所: アプリコード無改修 / 既存の SAVEPOINT 方式を維持
  • 短所: テスト時の transaction() の意味が本番と微妙に変わる(コミット境界に依存する処理があると差分になり得る)

案B: 問題が起きるテストだけテストによるtransactionの仕様を避けてDBをリセットする方式にする

  • 長所: db.transaction() が本番どおり動く(実コミット前提で検証できる)
  • 短所: SAVEPOINT より遅くなりがち / ID の連番などに注意点が出る

案C: テストごとに DB を作り直す / スキーマを作り直す

  • 長所: 最も強い分離
  • 短所: 遅い・運用が重い(ローカル/CI ともに負担)

解決策: ファイル単位で分離方式を切り替える

今回は案Bを採用しました。理由は db.transaction() が本番と同じ挙動で動くことを重視したためです。

ただし、すべてのテストを TRUNCATE 方式にすると遅くなるので、「普段は SAVEPOINT で高速に回す」を維持しつつ、db.transaction() を使うテストだけ「TRUNCATE(reset)」にする、という形にしました。

ポイントは、実行コマンドで切り替えるのではなく、テストファイルに宣言的に書く形にしたことです。忘れにくく運用しやすいですし、AI にテストを書いてもらう場合も「このファイルは reset 方式で」と指示するだけで済みます。というか指示しなくても周囲の参考ファイルを見て勝手にかき分けてくれそうです。

実装

1) セットアップは「接続と db モックだけ」にする

apps/api/src/test/setup.ts は、以下だけを担当します。

  • pgClient.connect()/end()
  • vi.mock("../db", () => ({ db: testDb }))

分離(BEGIN/SAVEPOINT/TRUNCATE)はここではやりません。

2) テスト分離の方式を helper として用意する

apps/api/src/test/isolation.ts に 2 つの API を用意しました。

  • useSavepointIsolation()

    • beforeAll: BEGIN
    • beforeEach: SAVEPOINT
    • afterEach: ROLLBACK TO SAVEPOINT
    • afterAll: ROLLBACK
  • useResetIsolation()

    • afterEach: drizzle-seed の reset()
    • PostgreSQL の場合は TRUNCATE ... CASCADE(Drizzle の schema 対象テーブル)

使う側はテストファイルの先頭で 1 行書くだけです。

import { useSavepointIsolation } from '../test/isolation'

useSavepointIsolation()

db.transaction() を使うテストファイルは、こちらにします。

import { useResetIsolation } from '../test/isolation'

useResetIsolation()

3) 実際の適用

今回問題が顕在化した meals ルートは useResetIsolation() を選びました。

// apps/api/src/routes/meals.test.ts
useResetIsolation()

他のルートのテストは useSavepointIsolation() のままです。

運用メモ

  • drizzle-seed reset() は Postgres の場合 TRUNCATE ... CASCADE ですが、RESTART IDENTITY は付きません。「ID が毎回 1 になる前提」のテストは作らない方が安全です(factory で作った ID を使う、など)。
  • describe.concurrent() / it.concurrent() は避ける必要があります。同一ファイル内のテストが並列実行されると、例えばテストAが INSERT している最中にテストBが TRUNCATE してデータが消える、といった競合が起きます。SAVEPOINT 方式でも、複数テストが同時に SAVEPOINT を作ったり ROLLBACK すると順序が壊れます。

まとめ

  • SAVEPOINT 方式は速くて便利ですが、Drizzle の db.transaction() と同居させると衝突しやすいです。
  • すべてを TRUNCATE に寄せると安全ですが遅くなります。
  • 今回は「通常は SAVEPOINT、transaction 系だけ reset」をテストファイル単位で選べるようにして、速度と確実性を両立させました。
  • AI でテストを書く場合も、ファイル先頭に 1 行書くだけなので運用しやすく、手数も問題にならなさそうです。

おわりに

SAVEPOINT 方式はテストが高速で気に入っていたのですが、Drizzle の transaction() との相性でハマりました。
Drizzle 側でネストトランザクションが対応されれば解決するかもしれませんが、対応される雰囲気もそこまで強くはなさそうです。
同じ問題に当たった方の参考になれば幸いです。

share