/note/tech

Testcontainersで実DBを使う並列テスト基盤を設計する

要約:

■ 1. 実DBを使う方針

  • Mockへの依存を減らす目的:
    • RepositoryをMockすればUseCaseのテストをDBから切り離せるが、確認できるのはMockに定義した振る舞いを前提とした正しさのみ
    • 実際のSQL、ORMのマッピング、DBの制約、Transactionは検証されない
    • 実装を変えるたびにMock側の追随が必要で、漏れればMockの振る舞いが実際の振る舞いから乖離する
  • Mockを全廃するわけではない:
    • 外部APIなど、実物をテストに組み込むことが適切でない依存もある
  • 実DBの適用範囲:
    • DBはTestcontainersを使えば比較的低いコストで実物を用意できる
    • Repositoryだけでなく、HTTPリクエストを投げるControllerのテストまで実DBに接続する
  • 実DBのコスト:
    • 実際に読み書きする分、テスト1件あたりの実行時間はMockより長くなる
    • テスト前にMigrationでスキーマを揃える手間と、書き込んだデータをテストごとに戻す手間が乗る
    • 実行時間を抑えるため並列実行すると、テスト同士で状態が混ざらない仕組みが必要になる

■ 2. 前提とするテスト構成

  • 技術スタック:
    • バックエンドはKotlin、テスト対象のDBはPostgreSQL 18
    • Testcontainersでテスト実行時にDBコンテナを自動起動する
    • JUnit 5でテストの実行とテストクラス単位の並列実行を行う
    • FlywayでDBスキーマのMigration、HikariCPでコネクションプール、ExposedでDBアクセスを担う
  • アプリケーションの層構成:
    • レイヤードアーキテクチャを採用し、RepositoryがDBへの読み書きを担う
    • UseCaseがRepositoryを呼んでTransactionの境界を決める
    • ControllerがHTTPリクエストを受けてUseCaseを呼ぶ
  • コンテナの起動設定:
    • データディレクトリを512MiBのtmpfsに置き、耐久性に関する設定を切って起動する
    • fsync、full_page_writes、synchronous_commitをoffにする
    • テスト用のDBは失われても作り直せるため、ディスクへの書き込みを待つ必要がない
  • 計測環境:
    • 10コアのApple Silicon搭載Mac上で、Dockerにも10コアを割り当てて測定した値を用いる

■ 3. できあがった構成

  • 実DB前提で決めるべき論点:
    • テスト実行時にDBをどう用意するか
    • 並列実行するテスト同士をどう分離するか
    • DBの初期化コストをどう抑えるか、テストごとのデータをどうリセットするか
  • 選択した組み合わせ:
    • TestcontainersによるDBの自動起動
    • Database単位での分離と、Migration済みDatabaseの複製
    • TRUNCATEと初期データの再投入
  • 最終的な構造:
    • 1つのPostgreSQLコンテナ内に、Flyway Migration済みのTemplate Databaseを1つ置く
    • テストを実行するスレッドは、それぞれ自分専用のDatabaseを1つ持つ
    • Databaseはテストごとに作り直さず、全テーブルのTRUNCATEと初期データ再投入で使い回す

■ 4. DBをどう用意するか

  • Testcontainersを新規開発の段階から利用する:
    • docker compose up -d のように別途環境を準備する方式は問題につながる
    • DBを起動していなかったためテストが失敗する、ローカルとCIでテストの実行方法が異なる、といった問題が生じる
    • Testcontainersならテストのライフサイクルにコンテナのライフサイクルをひもづけられる

■ 5. 何を分離の単位にするか

  • 状態共有によるFlaky Test:
    • 実DBを共有したまま並列実行すると、単独では成功するテストがCIでは実行のたびに結果を変える
    • 原因は並列に動作するテストが同じDBの状態を操作するレースコンディション
    • 並列に動作するテスト同士でDBの状態を共有しないことを最初の方針とする
  • 並列実行は前提とする:
    • テストクラスの並列実行をやめると、スイート全体の実行時間が並列実行時の3倍以上になった
    • この差は無視できないため、並列実行を前提としたうえで独立性を確保する
  • テストごとのコンテナ起動は採らない:
    • コンテナは起動してから接続できるまで1秒前後かかる
    • コンテナごとにMigrationを流し直すため、テスト1件あたり1秒以上が準備に消える
    • コンテナ自体はテスト全体で1つだけ起動し、その中でDatabaseを分ける
  • SchemaではなくDatabaseで分ける理由:
    • Schemaで分けると、修飾名や search_path の操作で他スレッドのデータに到達する手段が残る
    • 接続先のDatabaseが別であれば他スレッドのデータに到達する手段はなく、誤って触ろうとすればエラーになる
  • Database分離の代償はメモリ:
    • Databaseを増やすとテーブルの実体がまるごと増え、tmpfs上の増分はそのままコンテナのメモリ使用量になる
    • 何も作っていないコンテナは70MiB、Databaseを16個作ると382MiB、同数をSchemaで作ると113MiB
    • 増分はDatabaseが+312MiB、Schemaが+43MiBとなる
    • Databaseを1つ作るとシステムカタログ一式も作られるため、増分はテーブルのデータ量そのものより大きい
  • 現状のスレッド数では問題にならない:
    • tmpfsは512MiBで確保しており、テスト実行中のコンテナのメモリ使用量は最大239MiBだった

■ 6. スレッドごとのDatabase割り当て

  • 必要な分離の粒度:
    • テストの数だけDatabaseを作る必要はなく、同時に実行されているテストの間で状態が混ざらなければ十分
  • JUnitの並列実行設定:
    • テストクラスは並列に実行し、クラス内のテストメソッドは親クラスと同じスレッドで順に実行する
    • 並列数は指定せず、JUnitの既定の動的戦略により実行環境のCPUコア数を基準に決まる
  • ThreadLocalによる割り当て:
    • Databaseをスレッドにひもづけ、ThreadLocalのキャッシュとして保持する
    • 各テストクラスは @BeforeTest で setup() を呼び、キャッシュがあればデータをリセットして再利用する
    • キャッシュがなければUUIDから名前を作ってDatabaseを新規作成する
  • 使い回しの効果と注意点:
    • same_thread設定により、1つのテストクラスは実行中ずっと同じDatabaseを使い続ける
    • Databaseが作られるのはそのスレッドで最初にDBテストが走ったときだけ
    • 並列数を上げればスレッドが増え、メモリの増分が積み上がるためtmpfsの上限に気を配る必要がある
  • 後始末は不要:
    • 作成したDatabaseはテスト終了時にDROPせず、テストJVMの終了に合わせてコンテナごと破棄する

■ 7. Migrationのコストをどう避けるか

  • Template Databaseからの複製:
    • PostgreSQLには既存のDatabaseをTemplateとして新しいDatabaseを作成する機能がある
    • コンテナ起動直後にDatabaseを1つだけ作ってFlyway Migrationを適用し、これをTemplateとして複製する
  • 複製元への接続に関する制約:
    • CREATE DATABASE ... TEMPLATE は複製元への接続が1本でも残っていると失敗する
    • Template Databaseに繋ぐ処理はコンテナの初期化の中で完結させ、Migrationと初期データ抽出の後に接続を閉じきる
    • 複製は初期化の後に始まるため、複製中にTemplate Databaseへ接続が張られることもない
  • 準備にかかる時間:
    • Flyway MigrationはJVM内で最初の1回のみで500〜544ms
    • CREATE DATABASE ... TEMPLATE は競合のない状態で17ms前後、テスト実行中の競合下では68〜96ms
  • 複製方式の利点:
    • Flywayが走るのは全体で一度だけになる
    • スレッドやMigrationを増やしても1スレッドあたりの準備は複製1回のまま
    • 伸びるのはTemplate Databaseが大きくなった分のコピー時間だけ

■ 8. テスト間で状態をどう戻すか

  • リセットが必要な理由:
    • Databaseをスレッド単位で使い回す以上、テストが書き込んだデータはテストごとに初期状態へ戻す必要がある
  • Transactionで囲んでRollbackする案は不採用:
    • Repositoryレベルのテストであれば高速かつシンプルだが、Controllerレベルまでテストしているため採用できない
    • アプリケーション自身がTransactionを開始しCommitし、コネクションプールから自分で接続を取る
    • テストが張った接続とは別のため、Commitしていない変更はアプリケーション側から見えない
  • 複数Transactionの検証:
    • UseCaseによっては1つの処理中に複数のTransactionを使う
    • 先のTransactionはCommitされ、後のTransactionは失敗してRollbackされる振る舞い自体を検証したいケースがある
    • テスト全体を1つのTransactionで包む方式では、本番と同じTransaction境界を保った検証が難しくなる
    • テスト基盤側はRollbackに依存せず、アプリケーションには通常通りCommitやRollbackをさせる
  • Databaseを作り直す案も不採用:
    • Databaseを作り直すにはそのDatabaseへの接続を全部閉じる必要があり、プールの張り直しも伴う
    • 同時実行数1ではTRUNCATE+初期データ復元が11.41ms、DROP+CREATE TEMPLATEが9.51ms、プール張り直し込みで20.12ms
    • 同時実行数10ではそれぞれ27.20ms、47.15ms、67.11msとなり順位が逆転する
    • Databaseを分けて独立するのは中のデータだけで、Databaseを作る処理自体はサーバ全体で競合するため
  • 作り直しが遅い内訳:
    • 遅いのは DROP より CREATE の側で、DROP を外しても同時実行数10で35.61msとTRUNCATE方式に届かない
    • 複製したDatabaseは1つ8.5MiBあり、数百回規模のリセットが走るため512MiBのtmpfsでは DROP を外す選択肢自体がない
    • 実際にリセットを作り直しに差し替えると、どの回もTRUNCATE方式より遅くなった
  • 採用したTRUNCATE方式:
    • DDLを実行するテストがない限り、テストが変更するのはデータだけでSchema構造は残る
    • Databaseそのものは再利用し、各テストの開始時に全テーブルをTRUNCATEして初期データを再投入する
    • TRUNCATE ... RESTART IDENTITY CASCADE を1つの文にまとめ、外部キーの参照順を気にせず消せるようにする

■ 9. Template Databaseから抽出する初期データ

  • pg_dumpによる抽出:
    • Migration適用済みのTemplate Databaseから、データだけをINSERT文として抽出しリセット時に流す
    • --data-only、--inserts を指定し、flyway_schema_history は除外する
    • 初期データの定義はMigrationに一本化され、データ投入Migrationを追加してもテスト側の手当ては不要になる
  • つまずき1: 出力に混じるセッション設定:
    • pg_dump の出力にはINSERT文以外も含まれ、特にセッション設定が問題になる
    • set_config('search_path', '', false) をそのまま流すと、search_pathが空の接続がプールに返却され後続のテストが壊れる
    • 近年の pg_dump は \restrict / \unrestrict というpsqlのメタコマンドも出力し、これもJDBCからは流せない
    • INSERT INTO と SELECT pg_catalog.setval( で始まる行だけを取り出して対処する
    • 行の先頭で判定しているため、値に改行を含むデータがあると2行目以降を取りこぼす
  • つまずき2: シーケンスとデータのずれ:
    • TRUNCATE ... RESTART IDENTITY はシーケンスを巻き戻す一方、pg_dump のINSERT文はIDを明示して入れる
    • INSERT文だけを流すと、テーブルにはid=1, 2の行が入るのにシーケンスは1のままとなる
    • 次にアプリケーションがINSERTするとid=1が採番されて主キー衝突する
    • pg_dump が出力する setval を一緒に流すことで、シーケンスがデータと整合する

■ 10. リソースごとにライフサイクルを変える

  • 二律背反の解消:
    • すべてをテストごとに作り直せば実行速度が問題になり、すべてを共有すれば並列テスト同士が干渉する
    • そこでリソースごとにライフサイクルを変える
  • 採用したライフサイクル:
    • PostgreSQL ContainerはTest Suite全体で1つ
    • Databaseはスレッドごとに1つとし、複数のテストクラスで使い回す
    • Schema定義はMigration済みのTemplateから複製する
    • データはテストごとにTRUNCATEして再投入する
  • Testcontainersの位置づけ:
    • 実DBをテストに組み込む手段としては便利だが、それだけで並列テストの独立性や実行速度が決まるわけではない
    • どのリソースをどの単位で共有し、どの状態だけをテストごとに戻すかは、その上で1つずつ決める必要がある