はじめに
クラウドログ事業推進部でバックエンドエンジニアをしている馬渕です。
早いもので入社してから8ヶ月が経ちました。 最初はプロダクトのバックエンドの実装から始まり、インフラ周りを少しだけ、最近ではフロントエンドの実装にも挑戦しています。
今回はいろいろと経験させていただいた中から、データベース接続を伴うGoのユニットテストで実行時間の短縮をした内容を投稿します。
背景
クラウドログではバックエンドのユニットテストのうち、データベース接続を伴うテストについてはモックを使用していません。
それはなるべく本番に近い形でユニットテストを行うことで品質を担保しているからです。
そのため現状のテストの構成では、お互いのテストのデータが干渉しないように直列に実行するようになっています。
課題
その結果、開発速度が低下する、という課題を抱えることとなりました。
それはすべてのテスト実行の時間がかかるからです。
背景でもお伝えした通り、お互いのテストデータが干渉しないようにするため、テストは直列で実行する必要があります。
ローカルでの開発においても、またCI上での実行においても時間がかかってしまうという課題となりました。
検討
これらの課題を解決するため、いくつかの案を元に検討を進めました。
- インメモリデータベースの利用
- データベースプールを作り、空き状況に応じて利用
- テストごとにトランザクションを利用
- テストごとにデータベースを生成・利用
1. インメモリデータベースの利用
テストにおいて永続化は必要ないため、アクセス速度を上げる目的で検討に上がりました。
MySQL互換を謳うものも存在し、ORMとも動作するため、本番と同等の動作が期待できます。
一部、MySQLに非互換な部分も残っているので、採用には十分な調査が必要です。
2. データベースプール
同一のデータベースを参照していることが原因で直列に動作せざるを得ないため、CPUのコア数に応じて複数のデータベースインスタンスを立ち上げるデータベースプールが検討に上がりました。
テストは実行時に空いているデータベースを探して実行します。
テスト完了後にはクリーンアップが行われます。
そのため、データベースプールで確保している分までは並列に実行が可能となります。
3. トランザクションの利用
同一のデータベースに対する排他制御が問題なのであれば、テストごとにトランザクションをはり、テスト完了後にロールバックをする方法が検討に上がりました。
複数のテストが同時に実行したとしても、トランザクションによりお互いのデータが干渉することがなくなります。
トランザクションがはられている間は、別のテストがデータベースにアクセスすることができず、待ち時間が発生してしまうことには注意が必要です。
4. テストごとにデータベースを生成
こちらも同じく同一のデータベースへのアクセスが問題なのであれば、テストごとにデータベースを生成することで解決するのではないか、という観点から検討に上がりました。
テスト用のMySQLコンテナとしては1つですが、その中にデータベースを作成し、テスト完了後にDropします。
データベースの名前がバッティングする問題については、テストごとにハッシュ値を生成してデータベース名につけることで解決を図ります。
メリット・デメリット
続いて、それぞれの案についてメリット・デメリットをまとめました。
方針 | 速度 | 導入コスト | すべての型に対応 | 既存改修コスト |
---|---|---|---|---|
1. インメモリデータベース | ◎ | ◯ | ✗ | ◯ |
2. データベースプール | △ | △ | ◯ | ◯ |
3. トランザクション | △ | ◯ | ◯ | △ |
4. テストごとデータベース生成 | ◯ | ◯ | ◯ | ◯ |
これらを元に、
- 複雑な機構の導入は属人化してしまう可能性がある
- 単純な機構であれば応用が効く
という観点から、今回は4.を採用することにしました。
どう解決したか
テスト用のデータベースコンテナとしては1つを利用するのですが、その中でテストごとにデータベースを作成し、お互いのテストが干渉しないようにしました。
詳しくは次のセクションで説明します。
具体的なアプローチ
1. テスト用のデータベースを生成する構造体を作成
テスト用のデータベース周りを管理する、database_generator構造体と生成用の関数を作成します。
この構造体は、
- テスト用のデータベースの作成
- 初期データベース用のDumpファイル作成
- データベース削除
- コネクションクローズ
の機能を持っています。
type DatabaseGenerator interface { DumpDB() error GenerateDB() (gormDB *gorm.DB, error) DropDB() error CloseConnection() error } type databaseGenerator struct { cred Credential // 秘匿情報を保持するstruct db *sql.DB dbName string } func New() (DatabaseGenerator, error) { cred := config.GetDBCredential() dsn := fmt.Sprintf( "%s:%s@tcp(%s:%d)", cred.User, cred.Password, cred.Host, cred.DBPort, ) sqlDB, _ := sql.Open("mysql", dsn) return &databaseGenerator{ cred: cred, db: sqlDB, }, nil }
2. テストのMain関数でテスト用のデータベースをDump
マイグレーションファイルやseedファイルを元にテスト用の初期データベースを作成し、Dumpします。
func (dg *databaseGenerator) DumpDB() error { // gormインスタンスの生成、データベース名はランダムなsuffixが入る gormDB, dbName, _ := dg.createTestDB() // seedパッケージはマイグレーションやSeedの適用を管理します seed := NewSeed(gormDB) seed.Migrate() // テスト用のコンテナにmariadb-clientが必要です cmd := exec.Command( "mysqldump", "--single-transaction", "--skip-lock-tables", fmt.Sprintf("-h%s", dg.cred.Host), fmt.Sprintf("-u%s", dg.cred.User), fmt.Sprintf("-p%s", dg.cred.Password), dbName, ) output, _ := cmd.CombineOutput() file, _ := os.Create(<DUMP_FILE_PATH>) defer file.Close() _, _ := file.Write(output) return nil }
詳しい解説は割愛しますが、createTestDBファンクションはhashを使ったランダムなsuffixを付与したデータベース名でgorm.DBのインスタンスを生成し、合わせてdbNameも返します。
その後、seedパッケージによるマイグレーションを行い、mariadb-clientのコマンド、mysqldumpを使ってマイグレーションが完了したデータベースをDumpします。
この処理をテストのメイン関数で実行します。
func TestMain(m *testing.M) { dbGen, _ := database_generator.New() _, := dbGen.Dump() os.Exit(m.Run()) }
3. Dumpからの復元
各テストで実行するデータベースを生成するための関数を準備します。
この関数では、先程のDumpで作成されたファイルを元にデータベースを復元し、gorm.DBを返します。
func (dg *database_generator) GenerateDB() (*gorm.DB, error) { gormDB, _, _ := dg.createTestDB() schema, _ := os.ReadFile(<DUMP_FILE_PATH>) _ := gormDB.Exec(string(shcema)).Error return gormDB, nil }
4. データベース作成、テスト実行、データベース削除、コネクションクローズ
各テストケースの実行前に、database_generateインスタンスを生成し、データベースを作成します。
このデータベースはこのテストケースでのみ使われることになります。
func Test_Hoge(t *testing.T) { dbGen, _ := database_generator.New() testDB, _ := dbGen.GenerateDB() // setup // テスト用のデータ投入などを行う // run tests ... // teardown _ := dbGen.DropDB() _ := dbGen.CloseConnection() }
テストの実行が完了したあと、データベースの削除とコネクションを閉じます。
5. 並列で実行するための処理を追加
パッケージ内のテストを並列で実行するため、t.Parallel()を追加します。
func Test_Hoge(t *testing.T) {
t.Parallel()
...
}
パッケージ内で並列にテストを実行するためには、この記述が必要です。
6. テスト実行コマンドを編集
パッケージ内で並列実行したい数をコマンドで指定します
指定しなかった場合、利用可能なCPUの数が指定されます
go test ./... -parallel <並列化したい数>
苦労した点
1. 動作確認にとにかく時間がかかる
実装後に正しく動作するか確認するのですが、その待ち時間が非常に多く発生します。
また並列化に伴い、データベース以外のところでデータ干渉が発生するケースもあったため、原因の特定には苦労しました。
2. 環境差異
ローカルでは上手く動作していたものの、CI上でテストをすると上手くいかない、という環境差異に悩まされました。
環境が違う状況で実行するものについては、早い段階でそれぞれの環境上のテストが必要だと反省しました。
おわりに
これらの対応により、約20%程度、実行時間を削減することができました。
もしデータベース接続を伴うGoのユニットテストに課題を抱えていらっしゃいましたら、今回の記事が参考になると幸いです。
お読みいただきありがとうございました。