testcontainers for go + flyway で実現する統合テスト
対象読者
- Go を採用した開発環境に統合テストを導入したい
- flyway を採用した開発環境に統合テストを導入したい
- testcontainers + flyway の組み合わせに興味がある
概要
開発言語が Go でマイグレーションに flyway を使用している環境で統合テストを導入しようとした際に testcontainers for go が良さそうだったので実装してみました
メリット
- 開発者が dockerfile などの docker 周りを意識せず開発できる
- テスト実行者側は docker 周りの知識がほぼ不要
- テストごとに独立した DB を立ち上げられるため並列化が容易
デメリット
- テストごとに DB を立ち上げる都合上オーバヘッドが大きく、統合テストの実行時間が長くなりやすい
- 並列化の制限がある環境(無料の Github Actions など)の場合は並列感の恩恵を受けづらい
- testcontainers 固有の問題に当たった場合はちょっと大変
解説
DB(MySQL)
mysqlC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: mysqlImage,
Env: map[string]string{
"MYSQL_DATABASE": dbName,
"MYSQL_ALLOW_EMPTY_PASSWORD": "yes",
},
ExposedPorts: []string{fmt.Sprintf("%d/tcp", dbPort)},
Tmpfs: map[string]string{"/var/lib/mysql": "rw"},
Networks: []string{networkName},
NetworkAliases: map[string][]string{
networkName: {dbContainerName},
},
WaitingFor: wait.ForLog("port: 3306 MySQL Community Server"),
},
Started: true,
})
処理の高速化
Tmpfs: map[string]string{"/var/lib/mysql": "rw"},
Tmpfs を利用することで高速化を図っています
起動完了の待機
WaitingFor: wait.ForLog("port: 3306 MySQL Community Server")
3306ポートをチェックする形だと起動完了前に起動完了と判断してしまうケースがあるため、MySQL の起動完了ログをチェックしています
また、testcontainers では MySQL 用のモジュールも用意されているためこちらを利用しても OK です
flyway
mysqlDBUrl := fmt.Sprintf("-url=jdbc:mysql://%s:%d/%s?allowPublicKeyRetrieval=true", dbContainerName, dbPort, dbName)
flywayC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: flywayImage,
Cmd: []string{
mysqlDBUrl, "-user=root",
"baseline", "-baselineVersion=0.0",
"-locations=filesystem:/flyway", "-validateOnMigrate=false", "migrate"},
Networks: []string{networkName},
Files: []testcontainers.ContainerFile{
{
HostFilePath: "../migrations",
ContainerFilePath: "/flyway/sql",
FileMode: 644,
},
},
WaitingFor: wait.ForLog("Successfully applied").WithOccurrence(1),
},
Started: true,
})
マイグレーションの実行
Cmd: []string{
mysqlDBUrl, "-user=root",
"baseline", "-baselineVersion=0.0",
"-locations=filesystem:/flyway", "-validateOnMigrate=false", "migrate"},
-
"baseline", "-baselineVersion=0.0": マイグレーション履歴の定義のために必要です。未指定の場合は以下のエラーが発生します
Database: jdbc:mysql://mysql:3306/mysql?allowPublicKeyRetrieval=true (MySQL 8.0)
ERROR: Found non-empty schema(s) `mysql` but no schema history table. Use baseline() or set baselineOnMigrate to true to initialize the schema history table.
-
"-locations=filesystem:/flyway": 後続のContainerFilePathと合わせてマイグレーションファイルのディレクトリを指定しています
詳細: https://documentation.red-gate.com/fd/flyway-locations-setting-277579008.html -
"-validateOnMigrate=false": flyway にはマイグレーションファイルの検証がありますが、ファイル数が多いと実行に時間がかかり、テストを行なう上では不要なため無効にしています
詳細: https://documentation.red-gate.com/fd/validate-277578898.html
マイグレーション実行完了の待機
WaitingFor: wait.ForLog("Successfully applied").WithOccurrence(1)
DB(MySQL) と同様にマイグレーション完了時のログをチェックしています
マイグレーション後の後始末
defer func() {
if flywayC.IsRunning() {
if err = flywayC.Terminate(ctx); err != nil {
panic(err)
}
}
}()
マイグレーション完了後はコンテナが不要なので削除しています
テストの実行(一例)
func Test_Mutate(t *testing.T) {
cases := map[string]struct {
want string
}{
"create user": {want: "created user1"},
"create user2": {want: "created user2"},
"create user3": {want: "created user3"},
"create user4": {want: "created user4"},
}
for name, tt := range cases {
t.Run(name, func(t *testing.T) {
ctx := context.Background()
db, err := util.NewTestDB(ctx)
if err != nil {
t.Fatal("failed to create test db", err)
}
m := NewMutate(db)
actual, err := m.Execute(tt.want)
assert.NoError(t, err)
assert.Equal(t, tt.want, actual.Name)
})
}
}
t.Run() のなかで util.NewTestDB() を呼び出すことでテストケースごとに DB を起動しています
テストケース単位で DB が独立して起動するためテスト実行後のクリーンアップが不要になり、並列化による高速実行も可能になります
ただし、並列実行できない場合は並列化の恩恵より DB 起動のオーバーヘッドの方が勝ってしまうため逆にテスト実行が遅くなります
クリーンアップが必要になりますが、テスト関数単位で DB を立ち上げテストケース内で DB を共有したほうが早いです
注意点
過去に v0.34.0 を利用していた際には並列で DB を起動した際に他の DB にアクセスするレースコンディションのような挙動がありました
現在は直っていますが、当時は開発者に相談しても原因が判明しなかったため同様の問題に直面した際には並列化を諦めるか、原因究明するなどの対処が必要になります
Discussion