/note/tech

DBを使うGoのテストを速く・壊れにくくする

要約:

■ 1. 概要

  • DBを使うGoのテストを高速かつ壊れにくくするためのヘルパーパッケージの設計と実装を紹介する
  • 当初はモックを使用していたが、メンテナンスコストの高さとテスト対象のズレが問題となった
  • Testcontainersに移行したが、Dockerへの依存とコンテナ起動時間が課題となった
  • 解決策として「1行呼ぶだけでマイグレーション済みの空のDBが手に入る」ヘルパーパッケージを整備した

■ 2. 設計の方針

  • 提供する機能:
    • テストコードから1行呼ぶだけでマイグレーション済みの空のDBが使える
    • 実行環境を見てPostgreSQLのプロセス起動とTestcontainersを自動で切り替える
    • テストごとに独立したDBを高速に生成し、終わったら片付ける
  • APIの設計:
    • pi.GetDatabase(t) を呼ぶだけで *sql.DB が得られる
    • t.Cleanup を内側で呼び、テスト終了時に接続のクローズとDBの削除を自動で行う
    • パッケージ内で1つのPostgreSQLインスタンスを共有し、各テストケースでDBを複製する

■ 3. テンプレートDBによる高速化

  • マイグレーションをテストごとに実行するとスケールしないため、結果をキャッシュして再利用する
  • PostgreSQLのテンプレートデータベース機能を利用する:
    • PostgreSQLの起動直後にマイグレーション済みのテンプレートDBを1つ作成する
    • テストごとに CREATE DATABASE <random_name> TEMPLATE <template_db> を発行する
    • ファイルレベルのコピーで済むため、マイグレーション実行より大幅に高速
  • データベース名はランダムに生成し、t.Parallel() による並列テストでも各テストが独立したDBを持つ

■ 4. 環境差の吸収

  • 実行環境に応じてPostgreSQLの起動方法を切り替える:
    • macOS: pg_ctl 経由でプロセスを起動し、一時ディレクトリにdataディレクトリを作成する
    • Debian / GitHub Actions: pg_createcluster を使用し、GitHub Actionsランナーでは sudo 経由で呼び出す
    • Dockerのみの環境: Testcontainersを使用し、pg_bigm 拡張を含むDockerfileを動的に生成してビルドする
  • 環境の判定は pg_ctlpg_createcluster のパスの存在確認、および RUNNER_ENVIRONMENT 環境変数で行う

■ 5. ハマりどころ

  • panic時にPostgreSQLのプロセスが残る問題:
    • t.Parallel() で並列化したテストの1つがpanicすると、TestMainのdeferが呼ばれない
    • 起動したPostgreSQLが別のプロセスグループになるためゾンビ化する
    • 緩和策として、テストの失敗を検知した場合に他のテストの完了を30秒待ってからPostgreSQLを停止する
    • GitHub Actionsではrunnerごと使い捨てられるためこの処理は走らせない
  • t.Failed() がpanic時にfalseを返す問題:
    • Go標準ライブラリのバグ(golang/go#49929)として報告されている
    • ワークアラウンドとして、reflecttesting.commonfinished フィールドを参照しpanicを判定する
  • DBの起動完了を観測する必要がある問題:
    • プロセス起動の場合: 確保したポートへの接続と sql.DB.Ping() の成功まで待機する
    • コンテナ起動の場合: TestcontainersのWaitStrategyを使用して待機する

■ 6. さらなる高速化: プロセスの再利用

  • Goのテストはパッケージ単位で別プロセスとして実行されるため、パッケージごとにPostgreSQLの初期化・起動が行われる
  • GitHub Actions上ではPostgreSQLを一度起動して使い回す最適化を実施:
    • テスト実行前に専用コマンドでPostgreSQLを起動し、テンプレートDBまで作成する
    • プロセス情報をJSONファイルに書き出し、そのパスを環境変数で各テストプロセスに伝える
    • テスト側は環境変数を見て既存のプロセスを再利用する
    • teardownは意図的に行わない(runnerがジョブ終了時に破棄されるため)
  • 再利用による効果:
    • 通常の実行時間: 各パッケージ7〜8秒
    • 再利用時の実行時間: 各パッケージ247〜353ms(約3〜4%)

■ 7. 所感

  • 良い点:
    • テストを書く側はDBの詳細を意識せずに済む
    • 手元とCIで同じ抽象化を共有しているため「CIでだけ落ちる」が起きにくい
    • 並列テストでもデータが汚染されない
    • flakyテストが起きづらくなった
  • 残っている課題:
    • t.Failed() のワークアラウンドがreflectに依存しており、Go側の修正で壊れる可能性がある
    • ゾンビプロセスが稀に発生することがある
    • 実DBを使う以上、テストに必要な事前データは自分で用意しなければならない