Testcontainers のベスト プラクティス

Testcontainers は、開発およびテストのユース ケース用に使い捨てのオンデマンド コンテナーをプロビジョニングするためのオープン ソース フレームワークです。 テストコンテナを使用すると、データベース、メッセージブローカー、Webブラウザ、またはDockerコンテナで実行できるほぼすべてのものを簡単に操作できます。

ローカル開発に Testcontainers ライブラリを使用することもできます。Testcontainers ライブラリと Testcontainers Desktop を組み合わせると、快適なローカル開発とテストのエクスペリエンスが提供されます。 Testcontainers ライブラリは、 JavaGo.NETNode.jsPythonRubyRustClojureHaskell

この記事では、Testcontainers ライブラリを使用する際のいくつかのすべきこととすべきでないことについて説明します。 ここではJavaのコードスニペットを紹介しますが、この概念は他の言語にも適用できます。

Testcontainers: ベスト プラクティス

テストを固定ポートに依存しない

Testcontainers を使い始めたばかりの場合や、既存のテスト設定を Testcontainers を使用するように変換する場合は、コンテナーに固定ポートを使用することを検討してください。

たとえば、PostgreSQL テストデータベースがインストールされ、ポート 5432で実行されている現在のテストセットアップがあり、テストがそのデータベースと通信するとします。 手動でインストールしたデータベースを使用する代わりに、PostgreSQL データベースを実行するために Testcontainers を利用しようとすると、PostgreSQL コンテナーを起動し、ホスト上の固定ポート 5432 で公開することを考えるかもしれません。

ただし、テストの実行中にコンテナーに固定ポートを使用することは、次の理由からお勧めできません。

  • あなたまたはあなたのチーム メンバーは、同じポートで別のプロセスを実行している可能性があり、その場合、テストは失敗します。
  • 継続的インテグレーション (CI) 環境でテストを実行している間は、複数のパイプラインを並行して実行することができます。 パイプラインは、同じ固定ポートで同じ種類の複数のコンテナーを起動しようとする場合があり、ポートの競合が発生します。
  • テスト スイートをローカルで並列化すると、同じコンテナーの複数のインスタンスが同時に実行されます。

これらの問題を完全に回避するには、Testcontainers の組み込みの動的ポート マッピング機能を使用するのが最善の方法です。

// Example 1:

GenericContainer<?> redis = 
      new GenericContainer<>("redis:5.0.3-alpine")
            .withExposedPorts(6379);
int mappedPort = redis.getMappedPort(6379);
// if there is only one port exposed then you can use redis.getFirstMappedPort()


// Example 2:

PostgreSQLContainer<?> postgres = 
     new PostgreSQLContainer<>("postgres:16-alpine");
int mappedPort = postgres.getMappedPort(5432);
String jdbcUrl = postgres.getJdbcUrl();

テストに固定ポートを使用することは強くお勧めしませんが、ローカル開発に固定ポートを使用すると便利です。 これにより、たとえばデータベース検査ツールを使用する場合など、一貫したポートを使用してサービスに接続できます。 Testcontainers Desktop を使用すると、 固定ポートでこれらのサービスに簡単に接続できます

ホスト名をハードコードしない

テストに Testcontainers を使用する場合は、常にホストとポートの値を動的に設定する必要があります。 たとえば、Redisコンテナを使用した一般的なSpring Bootテストは次のようになります。

@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class MyControllerTest {

   @Container
   static GenericContainer<?> redis = 
        new GenericContainer<>(DockerImageName.parse("redis:5.0.3-alpine"))
             .withExposedPorts(6379);

   @DynamicPropertySource
   static void overrideProperties(DynamicPropertyRegistry registry) {
      registry.add("spring.redis.host", () -> "localhost");
      registry.add("spring.redis.port", () -> redis.getMappedPort(6379));
   }

   @Test
   void someTest() {
      ....
   }
}

熱心な観察者として、Redisホストを localhostとしてハードコーディングしていることに気付いたかもしれません。 テストを実行すると、コンテナーのマップされたポートに localhost を介してアクセスできるように構成されたローカルの Docker デーモンを使用している限り、CI で正常に動作し、実行されます。

ただし、リモート Docker デーモンを使用するように環境を構成すると、これらのコンテナーが localhost で実行されなくなるため、テストは失敗します。 したがって、テストを完全に移植可能にするためのベストプラクティスは、redis.getHost()を使用することです次のようにハードコードされた localhost の代わりに:

@DynamicPropertySource
static void overrideProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.redis.host", () -> redis.getHost());
    registry.add("spring.redis.port", () -> redis.getMappedPort(6379));
}

コンテナー名をハードコーディングしない

コンテナに名前を付けるには、次のようにします withCreateContainerCmdModifier(..)

PostgreSQLContainer<?> postgres= 
     new PostgreSQLContainer<>("postgres:16-alpine")
           .withCreateContainerCmdModifier(cmd -> cmd.withName("postgres"));

ただし、コンテナに固定/ハードコードされた名前を付けると、同じ名前で複数のコンテナを実行しようとすると問題が発生します。 これにより、複数のパイプラインを並行して実行しているときに CI 環境で問題が発生する可能性があります。

経験則として、特定の一般的なDocker機能(コンテナ名など)がTestcontainers APIで使用できない場合、これは統合テストのベストプラクティスの使用を促進する独断的な決定になる傾向があります。 これは withCreateContainerCmdModifier() 、非常に特殊なユースケースを持つが、Testcontainersの設計上の決定を回避するために使用すべきではない経験豊富なユーザー向けの高度な機能として利用できます。

ファイルをマウントするのではなく、コンテナにコピーする

テスト用のコンテナーを構成するときに、コンテナー内の特定の場所にいくつかのローカル ファイルをコピーできます。 典型的な例は、データベース初期化SQLスクリプトをデータベースコンテナ内のどこかにコピーすることです。

これは、次のようにローカルファイルをコンテナにマウントすることで構成できます。

PostgreSQLContainer<?> postgres =
   new PostgreSQLContainer<>("postgres:16-alpine")
    .withFileSystemBind(
          "src/test/resources/schema.sql",
          "/docker-entrypoint-initdb.d/01-schema.sql",
          BindMode.READ_ONLY);

これはローカルで機能する可能性があります。 ただし、リモートDockerデーモンまたはTestcontainers Cloudを使用している場合、これらのファイルはリモートDockerホストに見つからず、テストは失敗します。

ローカルファイルをマウントする代わりに、次のようにファイルコピーを使用する必要があります。

PostgreSQLContainer<?> postgres =
   new PostgreSQLContainer<>("postgres:16-alpine")
      .withCopyFileToContainer(
          MountableFile.forClasspathResource("schema.sql"),
          "/docker-entrypoint-initdb.d/01-schema.sql");

このアプローチは、リモートDockerデーモンまたはTestcontainers Cloudを使用している場合でも正常に機能し、テストを移植可能にします。

運用環境と同じコンテナー バージョンを使用する

コンテナー タグを指定するときは、新しいバージョンのイメージがリリースされたときにテストに不安定さが生じる可能性があるため、 latest を使用しないでください。 代わりに、運用環境で使用するのと同じバージョンを使用して、テストの結果を信頼できることを確認します。

たとえば、PostgreSQL 15.本番環境で2 バージョンの場合は、postgres:15を使用します。2 テストやローカル開発用のDockerイメージも必要です。

// DON'T DO THIS

PostgreSQLContainer<?> postgres = 
    new PostgreSQLContainer<>("postgres:latest");

// INSTEAD, DO THIS
PostgreSQLContainer<?> postgres = 
    new PostgreSQLContainer<>("postgres:15.2");

適切なコンテナライフサイクル戦略を使用する

通常、クラス内のすべてのテストには、次のように同じコンテナーが使用されます。

@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class MyControllerTest {

    @Container
    static GenericContainer<?> redis =
            new GenericContainer<>("redis:5.0.3-alpine")
                .withExposedPorts(6379);

    @DynamicPropertySource
    static void overrideProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.redis.host", () -> "localhost");
        registry.add("spring.redis.port", () -> redis.getMappedPort(6379));
    }

    @Test
    void firstTest() {
        ....
    }


    @Test
    void secondTest() {
        ....
    }
}

実行する MyControllerTestと、1 つの Redis コンテナーのみが起動され、両方のテストの実行に使用されます。 これは、Redisコンテナを 静的 フィールドにしているためです。 静的フィールドでない場合は、2 つのテストの実行に 2 つの Redis インスタンスが使用されますが、これは望ましくない可能性があり、Spring コンテキストを再作成していない場合は失敗する可能性があります。 テストごとに個別のコンテナーを使用することは可能ですが、リソースを大量に消費し、テストの実行が遅くなる可能性があります。

また、Testcontainers のライフサイクルに慣れていない開発者は、JUnit 5 Extension アノテーション@Testcontainersを使用したり@Containercontainer.start()、 そして container.stop() メソッド。 Testcontainers のライフサイクル方法を完全に理解するには、JUnit 5を使用した Testcontainers コンテナのライフサイクル管理ガイドをお読みください。

テストの実行を高速化するための別の一般的なアプローチは、 シングルトン コンテナー パターンを使用することです。

フレームワークの統合をTestcontainersに活用する

Spring BootQuarkusMicronautなどの一部のフレームワークは、Testcontainersにすぐに統合できます。これらのフレームワークのいずれかを使用してアプリケーションを構築する場合は、フレームワークの Testcontainers 統合サポートを使用することをお勧めします。

可能な場合は、ユーザーが事前設定したテクノロジー固有のモジュール

Testcontainersは、SQLデータベース、NoSQLデータストア、メッセージブローカー、検索エンジンなど、一般的なテクノロジーのほとんどに対応する テクノロジー固有のモジュール を提供します。 これらのモジュールは、SQL データベース コンテナーからの JDBC URL、Kafka コンテナーからの bootstrapServers URL の取得など、コンテナーの情報を簡単に取得できるテクノロジ固有の API を提供します。 最も重要なのは、必要なすべてのブートストラップ作業を処理し、コンテナでアプリケーションを簡単に実行し、Javaコードから対話できるようにすることです。

たとえば、PostgreSQL コンテナーの作成に を使用すると GenericContainer 、次のようになります。

GenericContainer<?> postgres = new GenericContainer<>("postgres:16-alpine")
       .withExposedPorts(5432)
       .withEnv("POSTGRES_USER", "test")
       .withEnv("POSTGRES_PASSWORD", "test")
       .withEnv("POSTGRES_DB", "test")
       .waitingFor(
          new LogMessageWaitStrategy()
              .withRegEx(".*database system is ready to accept connections.*\\s")
              .withTimes(2).withStartupTimeout(Duration.of(60L, ChronoUnit.SECONDS)));
postgres.start();

String jdbcUrl = String.format(
           "jdbc:postgresql://%s:%d/test", postgres.getHost(), 
           postgres.getFirstMappedPort());

Testcontainers PostgreSQL モジュールを使用すると、次のように簡単に PostgreSQL コンテナーのインスタンスを作成できます。

PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
String jdbcUrl = postgres.getJdbcUrl();

PostgreSQLモジュールの実装は、すでに賢明なデフォルトを適用しており、コンテナ情報を取得するための便利なメソッドも提供しています。

そのため、 を使用する GenericContainer代わりに、まず、目的のテクノロジの モジュールがモジュールカタログ に既にあるかどうかを確認します。

一方、カタログから重要なモジュールが欠落している場合は、直接使用する GenericContainer (または GenericContainer を拡張する独自のカスタム クラスを作成する) ことで、テクノロジを機能させることができる可能性が高くなります。

WaitStrategies を使用してコンテナーの準備ができていることを確認する

独自のモジュールを使用 GenericContainer または作成している場合は、適切な WaitStrategy を使用して、スリープを数 (ミリ秒) 秒間使用する代わりに、コンテナーが完全に初期化され、使用できる状態になっているかどうかを確認します。

//DON'T DO THIS
GenericContainer<?> container = new GenericContainer<>("image:tag")
                                                       .withExposedPorts(9090);
container.start();
Thread.sleep(2 * 1000); //waiting for container to be ready

container.getHost();
container.getFirstMappedPort();

//DO THIS
GenericContainer<?> container = new GenericContainer<>("image:tag")
       .withExposedPorts(9090)
       .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*\\n", 1));
container.start();

container.getHost();
container.getFirstMappedPort();

Testcontainers の言語固有のドキュメントで、すぐに使用できる WaitStrategies を確認してください。 必要に応じて、独自の実装を行うこともできます。 

注: WaitStrategy を構成しない場合、Testcontainers は、ホストから公開されているすべてのポートの接続をチェックする既定の WaitStrategy を設定します。

概要

Testcontainers ライブラリを使用する際のすべきこととすべきでないことのいくつかを検討し、より優れた代替手段を提供しました。 Testcontainers の Web サイトをチェックし て、フレームワークを効果的に使用する方法に関するその他のリソースを見つけてください。

さらに詳しく