この記事は夏のブログリレー10日目の記事です。
はじめに
こんちくわ。24Bのあきもです。以前ハッカソンで「rucQ」という合宿総合アプリを開発し、春合宿で実際に運用しました。

その後rucQは有志での開発からSysAd班での開発に体制を切り替え、新たなメンバーも迎え入れて9月の夏合宿に向けた改善を進めています。……とはいえ新メンバーが入ってきたのはフロントエンドだけで、バックエンドは相変わらずほぼ僕1人で開発しています。1人でバックエンドに関する全責任を負うのはとてもつらい!!ですが、同時に色々なことを自分の裁量で決められるのでのびのびと開発できて楽しくもあります。その一環としてTestcontainersを導入してなんか良い感じにしたので、今回はそのことについて書きます。
Testcontainers is 何?
名前のとおりですが、テストコードからコンテナを操作するためのライブラリです。これを使うと、テスト前にデータベースを手作業で立ち上げるなどといった非生産的な行為をしなくてよくなります。rucQはGoで書かれているので本記事ではTestcontainers for Goを用いたコードで説明しますが、いろいろな言語のライブラリがあるようです。

設定をcompose.yamlに集約したい
ここからが本題です。普通にTestcontainersを使おうとすると、テストコード内にコンテナの設定をベタ書きすることになります。例として、Quickstartにあるコードを見てみましょう。
func TestWithRedis(t *testing.T) {
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "redis:latest",
ExposedPorts: []string{"6379/tcp"},
WaitingFor: wait.ForLog("Ready to accept connections"),
}
redisC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
testcontainers.CleanupContainer(t, redisC)
require.NoError(t, err)
}
ContainerRequest
で色々指定していますね。でも、開発時にDocker Composeを使っているなら必要なコンテナに関する情報はComposeの設定ファイルに全部書かれているんじゃないでしょうか。だったらそれを読むようにしたいですよね。また、compose.yamlにまとめておくとDependabotやRenovateなどでイメージのバージョン更新を自動化できるというメリットもあります。そこで、Compose moduleを使ってみましょう。
composeContent := `services:
nginx:
image: nginx:stable-alpine
environment:
bar: ${bar}
foo: ${foo}
ports:
- "8081:80"
`
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stack, err := compose.NewDockerComposeWith(compose.WithStackReaders(strings.NewReader(composeContent)))
if err != nil {
log.Printf("Failed to create stack: %v", err)
return
}
err = stack.
WithEnv(map[string]string{
"bar": "BAR",
}).
WaitForService("nginx", wait.NewHTTPStrategy("/").WithPort("80/tcp").WithStartupTimeout(10*time.Second)).
Up(ctx, compose.Wait(true))
if err != nil {
log.Printf("Failed to start stack: %v", err)
return
}
defer func() {
err = stack.Down(
context.Background(),
compose.RemoveOrphans(true),
compose.RemoveVolumes(true),
compose.RemoveImagesLocal,
)
if err != nil {
log.Printf("Failed to stop stack: %v", err)
}
}()
serviceNames := stack.Services()
fmt.Println(serviceNames)
// Output:
// [nginx]
ここではYAMLを文字列として直接記述していますが、ファイルパスを指定して読み込むときにはcompose.NewDockerCompose("path/to/compose.yaml")
のように書けます。
これで、普通に開発で使うためにDocker Composeに書いたコンテナの設定をテストでも使い回せるようになりました。必要なサービスだけ立ち上げるといったことも可能です。
ポートの衝突を避ける
ContainerRequest
などで立ち上げるときには空いているポートがランダムに割り当てられますが、Composeで8081:80
のようにホストのポートを固定していると同じサービスを複数立てたときにエラーが出ます。むやみにコンテナを立てまくるとテストがバカみたいに遅くなるので、可能なら同じサービスは使い回すようにすべきでしょう。しかし、どうしても同じサービスを複数立てたいこともあると思います。そんなときは環境変数を活用することで良い感じに動的割り当てを実現することが可能です。
まず、compose.yamlでのポート指定を8081:80
から${NGINX_PORT:-8081}:80
などといった形に変えます。これはComposeの記法で、環境変数NGINX_PORT
が設定されていればその値を、指定されていなければデフォルト値8081
を使うというものです[1]。これにより、テストコード内からWithEnv
で割り当てるポートを変更できるようになりました。あとは肝心のポート指定ですが、0
を指定しておけばOSが空いているポートを割り当ててくれるので特に考えることはありません。
composeContent := `services:
nginx:
image: nginx:stable-alpine
environment:
bar: ${bar}
foo: ${foo}
ports:
- "${NGINX_PORT:-8081}:80"
`
// 省略
err = stack.
WithEnv(map[string]string{
"bar": "BAR",
"NGINX_PORT": "0",
}).
WaitForService("nginx", wait.NewHTTPStrategy("/").WithPort("80/tcp").WithStartupTimeout(10*time.Second)).
Up(ctx, compose.Wait(true))
こうすることで、普通にdocker compose up
したときには固定のポートが割り当てられるようにしつつ、テストでは動的にポートを割り当てることができます。
謎のNo such container
エラーが出る
複数のパッケージでTestcontainersを使うテストを同時実行するとNo such container
というエラーが出る現象に苦しんでいました。原因は未だによく分かっていないんですが、TESTCONTAINERS_RYUK_DISABLED
という環境変数をtrue
にすると解決するので多分Ryukの問題だと思います。Ryukはテストで使ったコンテナなどを片付けてくれるヤツですが、Composeを使う場合には明示的にDownすれば良いので無効化しても問題ないでしょう。
おわりに
rucQはハッカソン生まれのプロジェクトですが、こんな感じで開発体験の改善も行っています。多分合宿後にちょっとメンバー募集するので、興味がある人はぜひ!
明日の投稿者は@TwoSquirrelsです。楽しみ〜