PostgreSQL単体ならTestcontainersはもう触れている、というところまでは来たけれど、Kafka と Redis も混ざるとどう書けばいいのか迷う、というのはよくありますよね。この記事では Spring Boot 3.1+ で導入された @ServiceConnection を使って、3つのミドルウェアを同時に立ち上げる統合テストの書き方を整理します。

PostgreSQL単体のベーシックな構成は Spring BootとTestcontainersで結合テストを書く方法 で扱っているので、本記事はその続編として読んでもらえると分かりやすいです。

なぜマルチコンテナで統合テストを書くのか

DBだけのテストでは、Kafkaのトピック設定漏れや、Redisのシリアライザ不一致といったミドルウェア間の噛み合わせの問題は見つかりません。embedded-kafka や embedded-redis で代用する方法もありますが、本物のミドルウェアと挙動が微妙にズレることがあり、結局CIで踏み抜くケースが多いんですよね。

Testcontainersで実物を立ち上げてしまえば、本番に近い構成のまま統合テストを書けます。代わりに起動コストが気になるので、後半で再利用設定とコンテキストキャッシュの話もします。

依存関係の追加

build.gradle に testcontainers-bom と Spring Boot のテスト用モジュールを入れます。

dependencies {
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'org.springframework.boot:spring-boot-testcontainers'
  testImplementation 'org.springframework.kafka:spring-kafka-test'
  testImplementation platform('org.testcontainers:testcontainers-bom:1.20.4')
  testImplementation 'org.testcontainers:junit-jupiter'
  testImplementation 'org.testcontainers:postgresql'
  testImplementation 'org.testcontainers:kafka'
  testImplementation 'org.awaitility:awaitility'
}

PostgreSQL と Kafka には専用モジュールがありますが、Redis は薄いので GenericContainer で素直に立てます。ローカルには Docker Desktop か Colima、rootless Docker のいずれかが必要です。

@ServiceConnection が何を肩代わりしてくれるか

Spring Boot 3.1 より前は @DynamicPropertySourcespring.datasource.url などを手書きしていました。これが地味に面倒で、コンテナごとにプロパティ名を覚えておく必要があったんですよね。

@ServiceConnection を付けると、Spring Boot が PostgreSQLContainerKafkaContainer の型を見て ConnectionDetails を自動登録してくれます。spring.datasource.urlspring.kafka.bootstrap-servers も書かなくてよくなります。

ただし対応コンテナは限定的で、Redis を GenericContainer で立てる場合は自分で RedisConnectionDetails を組む必要があります。後で具体例を見ます。

3コンテナを共有する基底クラス

複数のテストクラスで毎回コンテナを立て直すと時間がかかるので、static フィールドで共有するのが定石です。

@SpringBootTest
@Testcontainers
public abstract class AbstractIntegrationTest {

  @Container
  @ServiceConnection
  static final PostgreSQLContainer<?> POSTGRES =
      new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"));

  @Container
  @ServiceConnection
  static final KafkaContainer KAFKA =
      new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));

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

  @TestConfiguration
  static class RedisConfig {
    @Bean
    RedisConnectionDetails redisConnectionDetails() {
      return new RedisConnectionDetails() {
        @Override
        public Standalone getStandalone() {
          return Standalone.of(REDIS.getHost(), REDIS.getMappedPort(6379));
        }
      };
    }
  }
}

static で宣言しているので JUnit 5 のライフサイクル上、テストクラスをまたいで同じコンテナが使い回されます。Redis 用に RedisConnectionDetails Bean を1つ書いてあげるだけで、PostgreSQL や Kafka と同じ感覚で spring.data.redis.* 系のプロパティ設定が不要になります。

Kafkaの送受信テスト

非同期のテストは Awaitility と組み合わせると素直に書けます。

class OrderEventIntegrationTest extends AbstractIntegrationTest {

  @Autowired KafkaTemplate<String, String> kafkaTemplate;
  @Autowired OrderEventListener listener;

  @Test
  void shouldConsumeOrderEvent() {
    kafkaTemplate.send("orders", "order-1", "{\"id\":\"order-1\"}");

    await().atMost(Duration.ofSeconds(10))
        .untilAsserted(() -> assertThat(listener.received()).contains("order-1"));
  }
}

テスト用トピックは NewTopic Bean を @TestConfiguration で1つ宣言しておくと、起動時に自動作成されて楽です。リバランスで詰まる場合は group.id をテストごとにユニークにしておくと安定します。詳しくは Spring Boot × Kafka でProducer/Consumerを実装する方法 でも触れています。

Redisのキャッシュ動作テスト

class UserCacheIntegrationTest extends AbstractIntegrationTest {

  @Autowired StringRedisTemplate redisTemplate;
  @Autowired UserService userService;

  @Test
  void shouldCacheUserOnSecondLookup() {
    userService.findById("u-1");
    userService.findById("u-1");

    assertThat(redisTemplate.opsForValue().get("user:u-1")).isNotNull();
  }
}

spring.data.redis.host などを書かなくても、先ほどの RedisConnectionDetails Bean だけで Lettuce が自動で繋いでくれます。Redis 全般の組み合わせ方は Spring BootからRedisを使う方法 を参考にしてください。

コンテナ再利用でローカル実行を速くする

テストを書いて回し始めると、起動時間が気になってきます。ローカルだけなら再利用設定で大きく速くなります。

まず ~/.testcontainers.properties に1行追加します。

testcontainers.reuse.enable=true

そしてコンテナ宣言側で withReuse(true) を付けます。

static final PostgreSQLContainer<?> POSTGRES =
    new PostgreSQLContainer<>("postgres:16-alpine")
        .withReuse(true);

コンテナイメージや環境変数、ポート設定が同一なら、JVM終了後もコンテナが残ったまま次回起動時に使い回されます。逆に withInitScript で読むSQLを変えると別のコンテナとして再生成されるので、スキーマ変更時は古いコンテナを docker rm で掃除しておくと混乱しません。

CIでは何を変えるか

CIでは再利用は基本オフにしてください。ジョブごとに環境が破棄される前提なので、再利用フラグが残っていると意味なくゴミを増やすだけです。代わりに効くのは Spring の ApplicationContext キャッシュで、テストクラスの @SpringBootTest の設定が同一なら同じコンテキストが共有されます。

  • @MockBean を多用しない(コンテキストが分裂します)
  • テストクラスを過剰に分けない
  • JUnit 5 の並列実行を入れるなら @Execution(SAME_THREAD) をコンテナ依存テストに付ける

このあたりを守るだけで、CIの実行時間は素直に縮みます。GitHub Actions の Docker daemon は素のままで動くので、特別な設定は通常不要です。rootless Docker を使う場合だけ DOCKER_HOST の指定を忘れずに。

外部HTTP APIが絡む場合は別途モックが必要になりますが、その話は Spring BootとWireMockで外部APIをモックする方法 にまとめてあるので、そちらと組み合わせると統合テスト基盤としてはひと通り揃います。

まとめ

@ServiceConnection のおかげで、PostgreSQL・Kafka・Redis を同時に立ち上げてもテスト側のコードはとてもシンプルになります。Redis だけは ConnectionDetails Bean を一つ書く必要がありますが、それ以外は型を見て自動で繋がるので、spring.datasource.url 系の手書きから解放されるのは大きいです。

ローカルは withReuse(true)、CIは ApplicationContext のキャッシュを効かせる、という方針で運用すれば、マルチコンテナでも現実的な速度で回せます。まずは基底クラスを1つ作るところから始めてみてください。