feature image

2025年8月27日 | ブログ記事

Testcontainersで最強のテスト環境を作ろう

この記事は夏のブログリレー10日目の記事です。

はじめに

こんちくわ。24Bのあきもです。以前ハッカソンで「rucQ」という合宿総合アプリを開発し、春合宿で実際に運用しました。

サークル旅行のためだけにアプリをこしらえる人たち
この記事は新歓ブログリレー6日目の記事です。 こんにちは! この記事では、今年の冬ハッカソンで開発した合宿総合アプリ『rucQ』について紹介します。冬ハッカソンは他にもとんでもない作品が並んでいますので、ぜひ他の作品のブログも見ていってください! traPの合宿 そもそも科学大デジタル創作同好会traPにおける合宿とはどんなものなのか、と疑問に思う方もいらっしゃると思います。みんなで宿に泊まり込んでエナジードリンクを片手に創作や開発に没頭……というわけでは決してなく(そういう人がいないとは言わないけど)、traPの合宿は合宿とは名ばかりの単なる団体旅行です。昨年の夏合宿についてはAlterさんの記事で詳しく紹介されています。今回の冬合宿では約100人の参加者一同が観光バスで草津に向かい、2泊3日の期間でスキーや温泉を満喫してきました。 traPの合宿を統括するのは合宿係と呼ばれる3人の部員です。当日のアナウンスや誘導のみならず、合宿のしおりの執筆、参加費の徴収、宿の予約、出欠管理、フィードバックの募集に至るまで合宿に関するあれこれを一手に担うスーパーエリートな方々です。冬ハッ

その後rucQは有志での開発からSysAd班での開発に体制を切り替え、新たなメンバーも迎え入れて9月の夏合宿に向けた改善を進めています。……とはいえ新メンバーが入ってきたのはフロントエンドだけで、バックエンドは相変わらずほぼ僕1人で開発しています。1人でバックエンドに関する全責任を負うのはとてもつらい!!ですが、同時に色々なことを自分の裁量で決められるのでのびのびと開発できて楽しくもあります。その一環としてTestcontainersを導入してなんか良い感じにしたので、今回はそのことについて書きます。

Testcontainers is 何?

名前のとおりですが、テストコードからコンテナを操作するためのライブラリです。これを使うと、テスト前にデータベースを手作業で立ち上げるなどといった非生産的な行為をしなくてよくなります。rucQはGoで書かれているので本記事ではTestcontainers for Goを用いたコードで説明しますが、いろいろな言語のライブラリがあるようです。

Testcontainers
Testcontainers is an opensource library for providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

設定を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です。楽しみ〜


  1. Interpolation | Docker Docs ↩︎

akimo icon
この記事を書いた人
akimo

24B。Web開発とかお絵かきとかしてるオタク

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2024年9月17日
1か月でゲームを作った #BlueLINE
Komichi icon Komichi
2024年8月21日
【最新版 / 入門】JUCEを使ってVSTプラグインを作ろう!!!!【WebView UI】
kashiwade icon kashiwade
2021年8月12日
CPCTFを支えたWebshell
mazrean icon mazrean
2022年9月26日
競プロしかシラン人間が web アプリ QK Judge を作った話
tqk icon tqk
2022年9月16日
5日でゲームを作った #tararira
Komichi icon Komichi
2024年8月29日
クロスコンパイルRust
H1rono_K icon H1rono_K
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記