📦

並列化したgo testでDockerコンテナを効率的に使用するためのパッケージを作った

2022/10/11に公開

testcontainers/testcontainers-goory/dockertestはテストコードから必要なDockerコンテナを起動できて素晴らしい。でも一度起動させたコンテナを再利用しつつ、複数パッケージを並列でテストしたい…ということで、作りました。

http://github.com/daichitakahashi/confort

せっかく作ったので、使い方を解説してみようと思います。

Features

コンテナを起動させる機能自体は先述の2つのパッケージの二番煎じになりますが、加えて以下の特徴があります。

名前空間

PostgreSQLのコンテナを起動させることにします。

main_test.go
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リソースを確認する
$ 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
    • 第二引数forcefalseの場合、環境変数で設定された名前空間が優先されます
    • trueの場合環境変数に優先するため、特定のテストケースでのみ異なる名前空間を使うことが可能になります

コンテナの排他制御

コンテナを並列化したテストから使用するにあたり、共有ロックでなく排他ロックを獲得したい場合があります。
例えば次のように顧客を管理するテーブルと顧客ごとのユーザーを管理するテーブルがあった場合、顧客管理のテストと顧客のユーザー管理のテストが考えられます。

ユーザー管理機能のテストでは任意の顧客のみを対象とすれば良いと考えられるため、テストケースごとに顧客を登録すればテストの並列実行が可能になります。
一方で顧客管理機能のテストでは、任意の顧客が既に登録されていては困る場合があるでしょう。

先ほどTestMainで初期化したコンテナを使うテストコードは以下のように書くことができます。

db_test.go
// 顧客管理のテスト
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 packageBgo test ./...とすることで、複数のパッケージのテストを並列で実行することができます。先ほど見たように排他制御のためには初期化した変数を共有する必要があるため、これまでのコードではパッケージ単位の並列化に対応することができません。

これに対応するためにまずはテストコードに手を加えます。

main_test.go(*部分を追加)
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リソースを解放せずにテストを終了させることができます。

main_test.go(*部分を追加)
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_POLICYconfort testコマンドの-policyオプションでも変更することができます。

当然ですが、コンテナ上のリソースの初期化はユーザーの責務となります。
confort.WithInitFuncオプションを使うことで、初回のロック獲得時のみ実行されるリソース初期化処理を仕込むことができます。

その他

APIリファレンス

ここまで例として紹介したコードでは、ヘルスチェックを利用したコンテナの起動完了を待機する設定など細かい部分を省略しています。パッケージが公開するAPIの詳細はGoDocやREADMEを参照してください。
https://pkg.go.dev/github.com/daichitakahashi/confort

テストの実行順はランダムにしておこう

ユニットテストにDockerコンテナを使用するということは、テストコード以外に依存先が増えることを意味します。コンテナを使い回すにあたって、コンテナ上のリソースの状態管理が必要になる場合も考えられます。この状態管理が原因でテストの安定性が損なわれることがあってはなりません。

テストコードの不備を検知しやすくなるよう、テストの実行順はランダムにしておきましょう。

$ confort test -- -shuffle=on $(shuf -e $(go list ./...))

CIでのテスト

CIのテスト環境がDooD(Docker outside of Docker)で稼働している場合など、名前空間にCIのジョブIDを使うことで別のテストとの衝突を避けることができます。GitLab CIであれば以下のようになります。

.gitlab-ci.yml(一部)
variables:
  CFT_NAMESPACE: $CI_JOB_ID

さいごに

一通りの機能はそろえたつもりですが、まだまだ使いやすさや安定性の面で改善の余地があると思っています。お気づきの点がありましたら、気軽にissue報告やPRをもらえると大変嬉しいです。

Discussion