Goにおけるテスト用DB分離のテクニック
この記事は Go言語 Advent Calendar 2023 4日目の記事です。
導入
GoでMySQLやPostgreSQLのようなリレーショナルデータベースをDocker上で立ち上げテストに利用する手法は一般的です。この場合、異なるパッケージからの並行テスト実行によりテストデータの追加やクリーンアップ、データベース操作が重複することがあります。これにより、テストの数が増えるにつれ操作が競合したり、他のテストケースによる影響で期待される結果が得られないことがあるためFlakyテスト(不安定なテスト)の問題が生じます。
この記事では、テスト中のデータベース操作に関連する問題に対処する方法として TestMain
関数を使用し、テスト用データベースをパッケージごとに分離するアプローチについて紹介します。この方法は、テストの信頼性を向上させると同時にデータベース操作の競合を避けるための効果的な手段です。
背景:なぜデータベースの分離が必要なのか
Goのテスト環境では、同一パッケージ内のテストは直列に、異なるパッケージ間では並列に実行され、テスト時間の短縮が図られます。しかし、この並列実行はデータベース操作を含むテストにおいてFlakyテストを引き起こすデータ競合のリスクを孕んでいます。Flakyテストを防ぐことで信頼性の高い開発プロセスを確立し、ソフトウェアエンジニアの生産性を向上させることができます。
アプローチ:パッケージごとにデータベースを分離する
Goでは TestMain
関数を使ってパッケージ固有の共通処理を実装できます。この機能を利用して、テスト実行前にパッケージ専用のデータベースを準備し、同じデータベースでのデータ競合を防ぐことが可能です。
ory/dockertest を使ってデータベースのDockerコンテナを分離する方法もありますが、コンテナ起動に要する時間は無視できない点があります。そのため、同じ目的であれば TestMain
を使用する方が懸念が少ないと言えます。
実装サンプル
TestMain
関数で実施する必要がある手順は以下の通りです。
- rootユーザーのような、データベースを作成できる権限を持つユーザーでDBに接続する
- ランダムな名前でデータベースを作成する
- テストで使用する一般ユーザーに、そのデータベースへのアクセス権を付与する
- 一般ユーザーとして作成したデータベースに接続する
- マイグレーションを実行する
サンプルコードを実装してみたので実際に見てみましょう。
サンプルコードでは、ユーザー作成、記事投稿、記事一覧取得といった基本的な機能を実装しています。repository/mysql
とhandler
の2つのパッケージでDB接続のテストをしていますが、通常はhandler
でDB接続部分をモックする方が望ましいと思います。
CIでは、紹介する手法を使用するパターンと使用しないパターンの両方でテストを実行しています。手法を使用しない場合、他のパッケージのテストで追加されたデータが影響しCIが失敗することが確認できます。
gotestsum --format testname -- -race -shuffle on -count 100 -failfast ./...
=== Failed
=== FAIL: example/handler TestPostHandlerListPosts (0.02s)
post_test.go:83: got [{"id":1,"userId":1,"title":"Hello, world!","content":"This is a test post.","createdAt":"2016-01-01T00:00:00Z"},{"id":2,"userId":1,"title":"Hello, world! 2","content":"This is a test post 2.","createdAt":"2016-01-02T00:00:00Z"},{"id":10000,"userId":1,"title":"title","content":"content","createdAt":"2023-12-03T18:02:45Z"}]
, want [{"id":1,"userId":1,"title":"Hello, world!","content":"This is a test post.","createdAt":"2016-01-01T00:00:00Z"},{"id":2,"userId":1,"title":"Hello, world! 2","content":"This is a test post 2.","createdAt":"2016-01-02T00:00:00Z"}]
DONE 204 tests, 1 failure in 25.490s
Error: Process completed with exit code 1.
サンプルコードの解説
createMySQLDatabase
関数は、rootユーザーでMySQLに接続し、指定された名前のテスト用データベースを作成します。環境変数からrootパスワードとホストを取得し、デフォルトのデータベース名で接続設定を行います。新しいデータベースを作成した後、指定されたユーザーにそのデータベースに対する全権限を付与します。
TestWithMySQL
関数は、テスト用のランダムなMySQLデータベース名を生成し、そのデータベースをcreateMySQLDatabase
関数で作成します。この関数は環境変数からユーザー、パスワード、ホストを読み込み、生成されたDB名での接続設定を行い、sqlx
ライブラリを使用してデータベースに接続します。その後、与えられたスキーマに基づいてマイグレーションを実行し、テスト用のデータベース接続オブジェクトを返します。
これにより、各パッケージで隔離されたデータベース環境を準備できました。実際の TestMain
関数での使用例は以下の通りです。
// この変数を各テストケースで使う
var db *sqlx.DB
func TestMain(m *testing.M) {
var err error
db, err = testdb.TestWithMySQL()
if err != nil {
panic(err)
}
code := m.Run()
// os.Exit する場合は defer が実行されないので注意
if err = db.Close(); err != nil {
log.Printf("Failed to close database: %v", err)
}
os.Exit(code)
}
考慮すべき点
テスト用データベースを分離する際のいくつかの懸念事項を紹介します。他にも留意すべき点はあるかもしれませんが、以下が主なポイントです。
マイグレーションがボトルネックになる
今回の例では schema.sql
のみでマイグレーションを簡略化していますが、実運用のアプリケーションでは基本スキーマに加えて増分マイグレーションも必要になります。紹介したアプローチではマイグレーションをGoのプログラムから実行しているため、Go以外のライブラリを使用している場合は工夫が必要です(個人的には sqldef がおすすめ)。また、多くの増分マイグレーションを実行する場合、時間がかかる可能性がある点に注意が必要です。
テスト用DBを永続化する際の注意点
ローカル開発などでDockerを使用したDBのデータを永続化する場合、テスト時に同じコンテナを使用し続けると大量のデータベースが生成される可能性があります。MySQLではデータベース数に制限はない[1]ものの、PostgreSQLには制限が存在する[2]ため注意が必要です。
ジャストアイデアですが runtime.Caller
を使ってファイルパスからパッケージ名を推定し、その名前をハッシュ化してデータベース名として使用することでデータベースをパッケージごとに分離しつつ時間経過によって大量のデータベースが作成されることを回避できるかもしれません。
gotesplitとの相性問題
CIでテストを分割する便利なライブラリ Songmu/gotesplit があります。これはテスト高速化の文脈において非常に有用なライブラリですが、テスト一覧を取得する際にTestMain
関数を呼び出すため、今回の手法と相性が良くありません。特定の環境変数が設定されている際にデータベース分離処理をスキップするなどの回避策はありますが、何らかのワークアラウンドが必要です。
結論
この記事では、Goでのテスト時に異なるパッケージ間でデータベースを分離する手法を紹介しました。このアプローチにより、テストの信頼性と再現性が向上し、Flakyテストのリスクを低下させることができます。しかし、マイグレーションにかかる時間やCIツールとの相性問題など考慮すべき点もあります。データベースの分離は特に大規模なテストケースでは重要です。実装には工夫が必要ですが、テストの品質向上のためには有効な手法なので是非参考にしてみてください。
また、今回は簡単のため事前にDockerで立てたDBに接続する方法でサンプルコードを作成しましたが、ory/dockertest を使えばテストの実行に必要な準備をさらに減らせるかもしれません。今回紹介した手法との組み合わせも一考の価値があると思います。
Discussion