■ 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_ctlやpg_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)として報告されている
- ワークアラウンドとして、
reflectでtesting.commonのfinishedフィールドを参照し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を使う以上、テストに必要な事前データは自分で用意しなければならない