Tagbangers Blog

Spring + Testcontainers によるテストと ContextCustomizerFactory による効率化

参考記事

Faster tests by reusing Testcontainers in Spring Boot

Testcontainers の活用

Testcontainers は Java (JUnit) でテストを書く際に Docker コンテナを用いて実際の環境に近い Integration Tests を実現することのできる強力なソリューションです

タグバンガーズでも以下の事例で実際に Testcontainers を利用してテストを作成しているプロジェクトがあります

  • AWS S3 に対して操作を行うプロジェクトで LocalStack Module を用いて AWS SDK による操作を行うテスト
  • Keycloak Admin Client を用いてユーザ操作を行うプロジェクトで Keycloak Testcontainer を用いてテスト用の Keycloak に対して操作を行うテスト

Spring + Testcontainers の一般的な利用方法

Spring Boot のプロジェクトテンプレートを作成できる Spring Initializr では Testcontainers を Dependencies として追加できるようになっています

Maven プロジェクトの場合は BOM として Testcontainers のパッケージ全体のバージョンを管理して、個別に必要なパッケージを追加する形になります

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>testcontainers-bom</artifactId>
      <version>${testcontainers.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

Spring Initializr では JUnit 5 対応の junit-jupiter を始めとして、関連する他の Dependencies に合わせて必要なパッケージを追加してくれます

<dependencies>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

Jupiter / JUnit 5

JUnit 5 での利用方法はクラスに @Testcontainers アノテーションをつけて Testcontainer の利用を宣言し、static フィールドとしてコンテナを定義して @Container アノテーションをつけることでテスト開始時に自動でコンテナが起動します

@SpringBootTest
@Testcontainers
class DemoApplicationTests {

  static final DockerImageName POSTGRES_IMAGE_NAME = DockerImageName
      .parse("postgres")
      .withTag("11.16");

  @Container
  static final PostgreSQLContainer<?> POSTGRES_CONTAINER = new PostgreSQLContainer<>(POSTGRES_IMAGE_NAME)
      .withUsername("username")
      .withPassword("password");

  @Test
  void contextLoads() {
  }

}

Testcontainers がローカルにフォワーディングするポートは衝突することがないよう毎回ランダムに変わるため、@SpringBootTest などにおいて application.properties に相当するミドルウェアの設定をランタイムに行う必要があります

Spring は Testcontainers 用に @DynamicPropertySource というアノテーションを用意しており、これをつけた static なメソッドを用意することでランタイムに設定を登録することが可能です

@SpringBootTest
@Testcontainers
@Slf4j
class DemoApplicationTests {

  static final DockerImageName POSTGRES_IMAGE_NAME = DockerImageName
      .parse("postgres")
      .withTag("11.16");

  @Container
  static final PostgreSQLContainer<?> POSTGRES_CONTAINER = new PostgreSQLContainer<>(POSTGRES_IMAGE_NAME)
      .withUsername("username")
      .withPassword("password");

  @DynamicPropertySource
  static void registerProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", POSTGRES_CONTAINER::getJdbcUrl);
    registry.add("spring.datasource.username", POSTGRES_CONTAINER::getUsername);
    registry.add("spring.datasource.password", POSTGRES_CONTAINER::getPassword);
  }

  @Test
  void contextLoads() {
    log.info("Runs test with Testcontainers");
  }

}

起動すると以下のようなログが流れます(抜粋)

[INFO] Running com.example.demo.DemoApplicationTests
[main] :鯨: [testcontainers/ryuk:0.3.4]           : Creating container for image: testcontainers/ryuk:0.3.4
[main] :鯨: [testcontainers/ryuk:0.3.4]           : Container testcontainers/ryuk:0.3.4 is starting: ***
[main] o.t.utility.RyukResourceReaper           : Ryuk started - will monitor and terminate Testcontainers containers on JVM exit
[main] :鯨: [postgres:11.16]                      : Creating container for image: postgres:11.16
[main] :鯨: [postgres:11.16]                      : Container postgres:11.16 is starting: ***

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.5)

[main] com.example.demo.DemoApplicationTests   : Starting DemoApplicationTests using Java 17.0.5 on ***
[main] com.example.demo.DemoApplicationTests   : Runs test with Testcontainers
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.768 s - in com.example.demo.DemoApplicationTests

Spring の ApplicationContext が生成される(アスキーアートが表示される)前に Testcontainers がコンテナを起動します

testcontainers/ryuk は Testcontainers で利用するコンテナを管理するコンテナで、テスト終了時にコンテナを終了するなどの仕事をこなします

Spring + Testcontainers の問題点

基本的にはこれまで紹介した問題で運用が可能ですが Testcontainers はコンテナを立ち上げるという特性上セットアップのコストが高いです

テストによるボトルネックは以下の 2 点です

  • 不要なコンテナの再起動プロセス
  • 不要な ApplicationContext の再生成

不要なコンテナの再起動プロセス

@Testcontainers を用いた JUnit 5 の拡張機能を用いる場合 テストクラスごとにコンテナが立ち上がる という特性があります

これにより同じコンテナを用いる @SpringBootTest テストクラスが 2 個以上ある場合、その分コンテナが立ち上がり全体のテスト時間が大きくなってしまいます

例えば先ほどのテストをコピーして DemoApplication2Tests としてテストを起動します、すると下記のようなログになります

[INFO] Running com.example.demo.DemoApplicationTests2
[main] :鯨: [testcontainers/ryuk:0.3.4]           : Creating container for image: testcontainers/ryuk:0.3.4
[main] :鯨: [testcontainers/ryuk:0.3.4]           : Container testcontainers/ryuk:0.3.4 is starting: ***
[main] o.t.utility.RyukResourceReaper           : Ryuk started - will monitor and terminate Testcontainers containers on JVM exit
[main] :鯨: [postgres:11.16]                      : Creating container for image: postgres:11.16
[main] :鯨: [postgres:11.16]                      : Container postgres:11.16 is starting: ***

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.5)

[main] com.example.demo.DemoApplicationTests2   : Starting DemoApplicationTests using Java 17.0.5 on ***
[main] com.example.demo.DemoApplicationTests2   : Runs test with Testcontainers 2
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.768 s - in com.example.demo.DemoApplicationTests2
[INFO] Running com.example.demo.DemoApplicationTests
[main] :鯨: [postgres:11.16]                      : Creating container for image: postgres:11.16
[main] :鯨: [postgres:11.16]                      : Container postgres:11.16 is starting: ***

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.5)

[main] com.example.demo.DemoApplicationTests   : Starting DemoApplicationTests using Java 17.0.5 on ***
[main] com.example.demo.DemoApplicationTests   : Runs test with Testcontainers
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.768 s - in com.example.demo.DemoApplicationTests

Postgres コンテナイメージがそれぞれのテストで起動しています(Ryuk はテスト全体で 1 度のみ起動します)

またテストごとに ApplicationContext が生成されています(アスキーアートが 2 回出ている)

Singleton containers によるコンテナの再利用

コンテナイメージを再利用するための解決策の 1 つとして共通抽象クラスとしてドキュメントでは Singleton containers が提案されています

@SpringBootTest
@DirtiesContext // ①
abstract class AbstractTestcontainersTests {

  static final DockerImageName POSTGRES_IMAGE_NAME = DockerImageName
      .parse("postgres")
      .withTag("11.16");

  static final PostgreSQLContainer<?> POSTGRES_CONTAINER = new PostgreSQLContainer<>(POSTGRES_IMAGE_NAME)
      .withUsername("username")
      .withPassword("password");

  static {
    POSTGRES_CONTAINER.start();
  }

  @DynamicPropertySource
  static void registerPgProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", POSTGRES_CONTAINER::getJdbcUrl);
    registry.add("spring.datasource.username", POSTGRES_CONTAINER::getUsername);
    registry.add("spring.datasource.password", POSTGRES_CONTAINER::getPassword);
  }

}

@Slf4j
class DemoApplicationTests extends AbstractTestcontainersTests {

  @Test
  void contextLoads() {
    log.info("Runs test with Testcontainers");
  }

}

このクラスを継承することでテストごとにコンテナが立ち上がることを防げます

注意点としては junit-jupiter パッケージの @Testcontainers @Container アノテーションは使用できないという点です

そのため static コンストラクタにて手動でコンテナの起動が必要になります

テスト全体の終了時に Ryuk がコンテナを自動で終了してくれる点は変わりません

[INFO] Running com.example.demo.DemoApplicationTests2
[main] :鯨: [testcontainers/ryuk:0.3.4]           : Creating container for image: testcontainers/ryuk:0.3.4
[main] :鯨: [testcontainers/ryuk:0.3.4]           : Container testcontainers/ryuk:0.3.4 is starting: ***
[main] o.t.utility.RyukResourceReaper           : Ryuk started - will monitor and terminate Testcontainers containers on JVM exit
[main] :鯨: [postgres:11.16]                      : Creating container for image: postgres:11.16
[main] :鯨: [postgres:11.16]                      : Container postgres:11.16 is starting: ***

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.5)

[main] com.example.demo.DemoApplicationTests2   : Starting DemoApplicationTests using Java 17.0.5 on ***
[main] com.example.demo.DemoApplicationTests2   : Runs test with Testcontainers
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.768 s - in com.example.demo.DemoApplicationTests2
[INFO] Running com.example.demo.DemoApplicationTests
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.768 s - in com.example.demo.DemoApplicationTests

実行すると上記のように DemoApplicationTests の実行時にコンテナの立ち上がりもなく、ApplicationContext の生成も省略されました

基本的にこの方法で問題ないケースが多いですが、@DynamicPropertySource を抽象クラスで利用している点に注意が必要です

javadoc では以下のように記述しています

注意 : 基本クラスで @DynamicPropertySource を使用し、動的プロパティがサブクラス間で変更されるためにサブクラスでのテストが失敗することがわかった場合、各サブクラスが正しい動的プロパティを持つ独自の ApplicationContext を取得するように、基本クラスに @DirtiesContext のアノテーションを付ける必要があります。

@DirtiesContext を使用すると 不要な ApplicationContext の再生成 の問題が再び浮上します

また個人的に Java の仕様上 extends 対象は 1 クラスのみとなるため汎用性が低い問題点があると考えます

不要な ApplicationContext の再生成

クラスごとに @DynamicPropertySource を定義したり @DirtiesContext を用いるとテストのたびに ApplicationContext の再生成が行われます

Application Context の生成はコンテナの起動に比べるとかかる時間は短いですがテストクラスが多い場合には積み重なりテスト全体のコストアップにつながります

ContextCustomizerFactory を用いてコンテナを再利用可能にする

ApplicationContext ごとに一度生成したコンテナを再利用可能にします

Spring はデフォルトで ApplicationContext を特定のキーでキャッシュし再利用します

Context Caching

ContextCustomizerFactory というインターフェースを実装し登録することで独自の拡張を行えます

public class PostgresTestcontainerContextCustomizerFactory implements ContextCustomizerFactory {

  @Target(ElementType.TYPE)
  @Retention(RetentionPolicy.RUNTIME)
  @Documented
  @Inherited
  public @interface EnablePostgresContainer { // ①
  }

  @Override
  public ContextCustomizer createContextCustomizer(Class<?> testClass,
      List<ContextConfigurationAttributes> configAttributes) {
    if (!AnnotatedElementUtils.hasAnnotation(testClass, EnablePostgresContainer.class)) {
      return null;
    }
    return new PostgresTestcontainerContextCustomizer(); // ②
  }

  @EqualsAndHashCode // ③
  private static class PostgresTestcontainerContextCustomizer implements ContextCustomizer {

    private static final DockerImageName DOCKER_IMAGE_NAME = DockerImageName.parse("postgres").withTag("11.16");

    private static final String PROPERTY_SOURCE_NAME = "Postgres Testcontainers Properties";

    @Override
    public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) { // ④
      var container = new PostgreSQLContainer<>(DOCKER_IMAGE_NAME)
          .withUsername("username")
          .withPassword("password");
      container.start();
      context.getEnvironment().getPropertySources().addFirst(new MapPropertySource(PROPERTY_SOURCE_NAME, Map.of(
          "spring.datasource.url", container.getJdbcUrl(),
          "spring.datasource.username", container.getUsername(),
          "spring.datasource.password", container.getPassword())));
    }

  }

}
  1. テストクラスで利用するためのアノテーションを定義します(別ファイルで定義しても問題ないです)
  2. ContextCustomizerFactory#createContextCustomizer は全ての Spring テストクラスで呼ばれるため、定義したアノテーションが含まれるテストクラスのみ Testcontainers を利用するよう条件分岐を定義する
  3. ContextCustomizer をキャッシュするために equals と hashCode の定義が必須のため要注意です
  4. ContextCustomizer#customizeContext でコンテナとアプリケーションプロパティの管理を行います

利用するために /src/test/resources/META-INF/spring.factories に作成したクラスを登録します(複数定義している場合はカンマ区切りで追加可能です)

org.springframework.test.context.ContextCustomizerFactory=\
  com.example.demo.PostgresTestcontainerContextCustomizerFactory

利用するテストクラスで作成したアノテーションを付与するとそのテストでコンテナが起動して ApplicationContext への設定も行われます

@Slf4j
@SpringBootTest
@EnablePostgresContainer
class DemoApplicationTests {

  @Test
  void contextLoads() {
    log.info("Runs test with Testcontainers");
  }

}

ContextCustomizer が ApplicationContext のキャッシュとして登録されるため、同じアノテーションをつけている他の @SpringBootTest ではコンテナの起動やアプリケーションの起動が省略されてテストが非常に高速化します

コンテナとコンテキストの管理が別クラスに移動したことでコードの保守性が向上し、アノテーションベースのため 1 クラスに複数つけることが可能です

複数種類のコンテナを使用する場合はコンテナごとに同様の手順で ContextCustomizer を作ると良いでしょう

ContextCustomizerFactory の注意事項

ContextCustomizer に equals と hashCode メソッドを実装する

これらが抜けているとキャッシュが効きません、ContextCustomizer の javadoc にも説明があります

複数テストコンテナを定義した場合は組み合わせが違うと別々にキャッシュされる

これは ApplicationContext のキャッシュの仕様のため起こります

@SpringBootTest
@EnablePostgresContainer
@EnableLocalStackContainer
class DemoApplicationTests {

}

@SpringBootTest
@EnablePostgresContainer
class DemoApplication2Tests {

}

というような 2 つのテストクラスがある場合にどちらも @EnablePostgresContainer が定義されていますが別の ContextCustomizer 関連の定義の有無でテストクラスのキャッシュのキーが変わるためそれぞれのクラスで Postgres のコンテナが別々に立ち上がります

それぞれでキャッシュされているため、例えば下記のテストクラスが追加された際は DemoApplicationTests のキャッシュが再利用されるためコンテナの起動・ ApplicationContext の起動はそれぞれスキップされます

@SpringBootTest
@EnablePostgresContainer
@EnableLocalStackContainer
class DemoApplication3Tests {

}

重複がボトルネックになる場合は利用しない場合でもアノテーションをつけて組み合わせを同じにすれば問題ありません、包含したアノテーションを別途用意すると効率的です

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootTest
@EnablePostgresContainer
@EnableLocalStackContainer
public @interface ControllerIntegrationTest { // ①
}

不必要な @DirtiesContext を避ける

@DirtiesContext がついたクラスは ApplicationContext のキャッシュが効かないためコンテナの再起動が生じます

可能な限りつけないようにするか後述するテスト全体でのコンテナの再利用の強制を行うことをお勧めします

テスト全体で同じコンテナの再利用を強制する場合

コンテナによってはテスト全体で 1 つの再利用で問題ない場合があります

その場合はコンテナを static フィールドとして定義します

  @EqualsAndHashCode
  private static class PostgresTestcontainerContextCustomizer implements ContextCustomizer {

    private static final DockerImageName DOCKER_IMAGE_NAME = DockerImageName.parse("postgres").withTag("11.16");

    private static final String PROPERTY_SOURCE_NAME = "Postgres Testcontainers Properties";

    private static final PostgreSQLContainer CONTAINER = new PostgreSQLContainer<>(DOCKER_IMAGE_NAME)
        .withUsername("username")
        .withPassword("password");

    static {
      CONTAINER.start();
    }

    @Override
    public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) {
      context.getEnvironment().getPropertySources().addFirst(new MapPropertySource(PROPERTY_SOURCE_NAME, Map.of(
          "spring.datasource.url", CONTAINER.getJdbcUrl(),
          "spring.datasource.username", CONTAINER.getUsername(),
          "spring.datasource.password", CONTAINER.getPassword())));
    }

  }

これにより ApplicationContext のキャッシュに関係なくテスト全体で 1 つのコンテナが起動したまま再利用されます

ContextCustomizer の中に static フィールドとコンストラクタを入れ込むことで アノテーションがついたテストクラスの起動時に初めてコンテナが起動する ように遅延させることができます

これにより、コンテナが必要ないテストクラスのみ単体で起動する際に不要にコンテナが起動することを防ぐことができます

この手法は Testcontainers が提案する Singleton containers の手法とほぼ同じですがアノテーションベース、 ApplicationContext のキャッシュが効くという点でこちらの手法がアドバンテージが高いと考えます

ランタイムで Bean 登録してテストクラスから参照できるようにする

コンテナクラスにアクセスしたい場合は ConfigurableListableBeanFactory#registerResolvableDependency で Bean 登録することでテストクラスに Autowired することが可能です

    @Override
    public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) {
      var container = new PostgreSQLContainer<>(DOCKER_IMAGE_NAME);
      ...
      context.addBeanFactoryPostProcessor((beanFactory -> {
        beanFactory.registerResolvableDependency(PostgreSQLContainer.class, container);
      }));
    }
@Slf4j
@EnablePostgresTestcontainer
@SpringBootTest
class DemoApplicationTests {

  @Autowired
  PostgreSQLContainer container;

  @Test
  void contextLoads() {
    log.info("Runs test with Testcontainers");
    log.info("Postgres Container info: {}", this.container.getJdbcUrl());
  }

}
[main] com.example.demo.DemoApplicationTests    : Runs test with Testcontainers
[main] com.example.demo.DemoApplicationTests    : Postgres Container info: jdbc:postgresql://localhost:57908/test?loggerLevel=OFF