並列化したgo testでDockerコンテナを効率的に使用するためのパッケージを作った
testcontainers/testcontainers-goやory/dockertestはテストコードから必要なDockerコンテナを起動できて素晴らしい。でも一度起動させたコンテナを再利用しつつ、複数パッケージを並列でテストしたい…ということで、作りました。
せっかく作ったので、使い方を解説してみようと思います。
Features
コンテナを起動させる機能自体は先述の2つのパッケージの二番煎じになりますが、加えて以下の特徴があります。
名前空間
PostgreSQLのコンテナを起動させることにします。
var postgres *confort.Container
func TestMain(m *testing.M) {
ctx := context.Background()
cft, err := confort.New(ctx,
confort.WithNamespace("namespace", false), // 名前空間の指定
)
if err != nil {
log.Panic(err)
}
defer cft.Close()
postgres, err = cft.Run(ctx, &confort.ContainerParams{
Name: "db", // コンテナ名(Dockerネットワーク内のエイリアスとなる)
Image: "postgres:14.4-alpine3.16",
Env: map[string]string{
"POSTGRES_USER": dbUser,
"POSTGRES_PASSWORD": dbPassword,
},
ExposedPorts: []string{"5432/tcp"}, // コンテナの5432ポートをホストのエフェメラルポートにバインドする
})
if err != nil {
log.Panic(err)
}
m.Run()
}
confort.New
で指定の名前空間と紐づくコンテナのコントローラーを作成します。コントローラー作成時、名前空間を名前として持つDockerネットワーク(bridge)が作成され、このコントローラーによって作成されるコンテナはすべてこのネットワークに接続することになります。
confort.ContainerParams.Name
でコンテナの名前を指定することができますが、Dockerホストから見えるコンテナ名にはプレフィクスとして名前空間が付与されます。例の場合、以下のようなリソースが作成されます。
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
e0c683bffbe2 bridge bridge local
675ac6dd1ae1 host host local
469bf72fc1df namespace bridge local
9618a86793b5 none null local
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6a3d39ba28d0 postgres:14.4-alpine3.16 "docker-entrypoint.s…" 3 minutes ago Up 3 minutes 0.0.0.0:61707->5432/tcp namespace-db
namespace
というネットワークとnamespace-db
というコンテナが作成されていることがわかります。
さらにdocker inspectしてみましょう。
$ docker inspect namespace-db
[
{
#...
"NetworkSettings": {
#...
"Networks": {
"namespace": {
"IPAMConfig": null,
"Links": null,
"Aliases": [
"db",
"6a3d39ba28d0"
],
#...
}
}
}
}
]
ContainerParams.Name
で指定した名前がネットワーク内のエイリアスとして登録されています。つまり、それぞれの環境から以下のようなアドレスによってPostgreSQLにアクセスできるようになります。
- テストコード(Dockerホスト)から:
localhost:61707
- ネットワーク内の他のコンテナから:
db:5432
名前空間の設定方法
設定方法には2つの方法があります。
- 環境変数
CFT_NAMESPACE
-
confort.New
に使用するオプションconfort.WithNamespace
- 第二引数
force
がfalse
の場合、環境変数で設定された名前空間が優先されます -
true
の場合環境変数に優先するため、特定のテストケースでのみ異なる名前空間を使うことが可能になります
- 第二引数
コンテナの排他制御
コンテナを並列化したテストから使用するにあたり、共有ロックでなく排他ロックを獲得したい場合があります。
例えば次のように顧客を管理するテーブルと顧客ごとのユーザーを管理するテーブルがあった場合、顧客管理のテストと顧客のユーザー管理のテストが考えられます。
ユーザー管理機能のテストでは任意の顧客のみを対象とすれば良いと考えられるため、テストケースごとに顧客を登録すればテストの並列実行が可能になります。
一方で顧客管理機能のテストでは、任意の顧客が既に登録されていては困る場合があるでしょう。
先ほどTestMain
で初期化したコンテナを使うテストコードは以下のように書くことができます。
// 顧客管理のテスト
func TestCustomerManagement(t *testing.T) {
t.Parallel()
ports, release, err := postgres.UseExclusive(t, ctx) // コンテナの排他ロックを獲得する
if err != nil {
t.Fatal(err)
}
t.Cleanup(release) // ロックの解放をスケジュールする
conn, err := pgx.Connect(ctx,
fmt.Sprintf("postgres://%s:%s@%s", dbUser, dbPassword, ports.HostPort("5432/tcp")),
)
// test...
}
// ユーザー管理のテスト
func TestUserManagement(t *testing.T) {
t.Parallel()
ports, release, err := postgres.UseShared(t, ctx) // コンテナの共有を獲得する
if err != nil {
t.Fatal(err)
}
t.Cleanup(release) // ロックの解放をスケジュールする
conn, err := pgx.Connect(ctx,
fmt.Sprintf("postgres://%s:%s@%s", dbUser, dbPassword, ports.HostPort("5432/tcp")),
)
// test...
}
先ほど初期化したpostgres
変数を使って、コンテナのロック獲得とバインドされたホストポートの情報を取得することができます。
後はデータベースにアクセスするテストコードをゴリゴリ書いていきましょう。
注意点としては、confort.New
で作成したconfort.Confort
が排他制御を行うため、confort.Confort
ないしconfort.Container
を各テストケースで共有する必要があります。
複数パッケージテストの並列実行に対応する
ここからが本丸です。
Goではgo test packageA packageB
やgo test ./...
とすることで、複数のパッケージのテストを並列で実行することができます。先ほど見たように排他制御のためには初期化した変数を共有する必要があるため、これまでのコードではパッケージ単位の並列化に対応することができません。
これに対応するためにまずはテストコードに手を加えます。
func TestMain(m *testing.M) {
ctx := context.Background()
cft, err := confort.New(ctx,
confort.WithNamespace("namespace", false), // 名前空間の指定
confort.WithBeacon(), // *
)
// 以下省略
そしてテストの実行にはconfort
コマンドを使用します。
$ confort test -- ./...
confort
コマンドは排他制御のバックエンドとなるgRPCサーバーを立ち上げたあとにgo test
を実行します。confort.WithBeacon
オプションが排他制御のバックエンドを変更してくれるため、パッケージを跨いだ排他制御が可能になります(gRPCサーバーが見つからない場合はバックエンドを切り替えずに動作します)。
これでgo test -p=1 ./...
とおさらばすることができます…!
ユニークな識別子の生成
並列実行サポートの一環として、コンテナ上に作成するリソースの衝突を避けるためのユーティリティとしてunique
サブパッケージを用意しています。
uniqueCustomerName, err := unique.String(ctx, 12)
customerName := uniqueCustomerName.Must(t)
1行目ではunique.Unique[string]
型のランダムな12桁の英数字の生成器を初期化しており、2行目ではそれを利用して顧客名を生成しています。
unique.New
関数を使用することで、任意の文字列生成関数を使用することができます。
uniqueID, err := unique.New(ctx, func() (string, error) {
return uuid.NewString(), nil
})
型パラメータの制約はcomparable
であるため、ユニークな数値の生成にも使用できます。
unique.Unique
のインスタンスがそれまでに生成した値を全て保持しており、新しい値が生成されるまで引数の生成関数の実行を一定回数繰り返す仕組みとなっています。
パッケージを横断してユニークな識別子を生成する
confort
パッケージと同様、confort
コマンドとの組み合わせで実現可能です。
uniqueCustomerName := unique.String(12,
unique.WithBeacon(t, ctx, "customerName"),
)
customerName := uniqueCustomerName.Must(t)
gRPCサーバーに"customerName"という名前でストアを用意し、ストアにおいてユニークであることが確認できた値のみを返します。
コンテナの再利用
テスト駆動開発のようにテストコードの実行を繰り返しながら開発を進める場合、コンテナが起動するまでの数秒の時間が無視できないものになるかもしれません。
その場合リソースポリシーを"reusable"
に変更することで、Dockerリソースを解放せずにテストを終了させることができます。
func TestMain(m *testing.M) {
ctx := context.Background()
cft, err := confort.New(ctx,
confort.WithNamespace("namespace", false),
confort.WithBeacon(),
confort.WithResourcePolicy(confort.ResourcePolicyReusable), // *
)
// 以下省略
リソースポリシーには以下の種類があります。
-
"reuse"
: 名前が同じ既存のリソースを再利用し、自身が作成したリソースのみ解放する(デフォルト) -
"reusable"
: 既存のリソースを再利用し、また自身が作成したリソースの解放も行わない -
"error":
既存のリソースと名前が衝突するとエラーとなり、作成したリソースは解放する -
"takeover"
: 既存のリソースを再利用し、自身が作成したリソースと合わせて解放する
ポリシーはconfort.WithResourcePolicy
オプションの他、環境変数CFT_RESOURCE_POLICY
やconfort test
コマンドの-policy
オプションでも変更することができます。
当然ですが、コンテナ上のリソースの初期化はユーザーの責務となります。
confort.WithInitFunc
オプションを使うことで、初回のロック獲得時のみ実行されるリソース初期化処理を仕込むことができます。
その他
APIリファレンス
ここまで例として紹介したコードでは、ヘルスチェックを利用したコンテナの起動完了を待機する設定など細かい部分を省略しています。パッケージが公開するAPIの詳細はGoDocやREADMEを参照してください。
テストの実行順はランダムにしておこう
ユニットテストにDockerコンテナを使用するということは、テストコード以外に依存先が増えることを意味します。コンテナを使い回すにあたって、コンテナ上のリソースの状態管理が必要になる場合も考えられます。この状態管理が原因でテストの安定性が損なわれることがあってはなりません。
テストコードの不備を検知しやすくなるよう、テストの実行順はランダムにしておきましょう。
$ confort test -- -shuffle=on $(shuf -e $(go list ./...))
CIでのテスト
CIのテスト環境がDooD(Docker outside of Docker)で稼働している場合など、名前空間にCIのジョブIDを使うことで別のテストとの衝突を避けることができます。GitLab CIであれば以下のようになります。
variables:
CFT_NAMESPACE: $CI_JOB_ID
さいごに
一通りの機能はそろえたつもりですが、まだまだ使いやすさや安定性の面で改善の余地があると思っています。お気づきの点がありましたら、気軽にissue報告やPRをもらえると大変嬉しいです。
Discussion