Testcontainersを利用したSpring Bootアプリのテスト

今参画している案件でMySQL、Kafka、Cloud Pub/Sub、AWS SQS/SNSあたりを使ったアプリケーションを作成することになりそうなので・・・Testcontainersを利用してSpring Bootアプリケーションのテストをしようかな〜と考えています。今回はすご〜く簡単なデモアプリケーションを作ってみました。

今参画している案件では使わないけど・・・

* PostgreSQL
* MockServer(擬似HTTP Server)

を使ってテストするデモアプリケーションも作ってみました。

# 利用するもの

* Spring Boot 2.4.5
* Testcontainers 1.15.3
* JUnit 5
* Docker
* MySQL in Docker
* PostgreSQL in Docker
* Apache Kafka in Docker
* Cloud Pub/Sub Emulator in Docker
* Localstack(AWS Emulator) in Docker
* MockServer in Docker

# 作ったもの

GitHubのリポジトリとして公開してあります。

* https://github.com/kazuki43zoo/spring-boot-test-demo-with-testcontainers

# MySQL編

データベースとしてMySQLを使ったアプリケーションのテスト構成は以下のような感じになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117313/ab280141-9849-d8eb-4a5e-c1c72af66d5f.png)

* https://github.com/kazuki43zoo/spring-boot-test-demo-with-testcontainers/tree/main/mysql-test-demo
* https://www.testcontainers.org/modules/databases/mysql/

```java
package com.example.demo.mysql;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

@SpringBootTest
@Testcontainers
class MysqlTestDemoApplicationTests {

  @Container
  private static final MySQLContainer<?> mysql = new MySQLContainer<>(DockerImageName.parse("mysql"))
      .withUsername("devuser")
      .withPassword("devuser")
      .withDatabaseName("devdb"); // MySQLのコンテナを生成

  @Autowired
  MysqlTestDemoApplication.MyMapper mapper;

  @DynamicPropertySource
  static void setup(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", mysql::getJdbcUrl); // コンテナで起動中のMySQLへ接続するためのJDBC URLをプロパティへ設定
  }

  @Test
  void contextLoads() {
    Assertions.assertThat(mysql.isRunning()).isTrue();
    {
      MysqlTestDemoApplication.Sample sample = new MysqlTestDemoApplication.Sample();
      sample.id = 1;
      sample.name = "Test";
      mapper.create(sample); // データをMySQLへ追加
    }
    {
      MysqlTestDemoApplication.Sample sample = mapper.findOne(1); // MySQLへ追加したデータを取得
      Assertions.assertThat(sample.id).isEqualTo(1);
      Assertions.assertThat(sample.name).isEqualTo("Test");
    }
  }

}
```

# PostgreSQL編

データベースとしてPostgreSQLを使ったアプリケーションのテスト構成は以下のような感じになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117313/2a065c34-4dc9-8b7a-41e4-9db04d4ff5e3.png)

* https://github.com/kazuki43zoo/spring-boot-test-demo-with-testcontainers/tree/main/postgresql-test-demo
* https://www.testcontainers.org/modules/databases/postgres/


```java

package com.example.demo.postgresql;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

@SpringBootTest
@Testcontainers
class PostgresqlTestDemoApplicationTests {

  @Container
  private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres"))
      .withUsername("devuser")
      .withPassword("devuser")
      .withDatabaseName("devdb"); // PostgreSQLのコンテナを生成

  @Autowired
  PostgresqlTestDemoApplication.MyMapper mapper;

  @DynamicPropertySource
  static void setup(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl); // コンテナで起動中のPostgreSQLへ接続するためのJDBC URLをプロパティへ設定
  }

  @Test
  void contextLoads() {
    Assertions.assertThat(postgres.isRunning()).isTrue();
    {
      PostgresqlTestDemoApplication.Sample sample = new PostgresqlTestDemoApplication.Sample();
      sample.id = 1;
      sample.name = "Test";
      mapper.create(sample); // データをPostgreSQLへ追加
    }
    {
      PostgresqlTestDemoApplication.Sample sample = mapper.findOne(1); // PostgreSQLへ追加したデータを取得
      Assertions.assertThat(sample.id).isEqualTo(1);
      Assertions.assertThat(sample.name).isEqualTo("Test");
    }
  }

}
```

# Apache Kafka編

メッセージブローカーとしてApache Kafkaを使ったアプリケーションのテスト構成は以下のような感じになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117313/e4d49d3d-288c-729d-000b-08fce9efe555.png)

* https://github.com/kazuki43zoo/spring-boot-test-demo-with-testcontainers/tree/main/kafka-test-demo
* https://www.testcontainers.org/modules/kafka/

```java
package com.example.demo.kafka;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.kafka.core.KafkaOperations;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;

@SpringBootTest
@Testcontainers
class KafkaTestDemoApplicationTests {

  @Container
  static final KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka")); // Kafkaのコンテナを生成

  @Autowired
  KafkaOperations<?, ?> kafkaOperations;

  @Autowired
  BlockingQueue<Message<String>> messages;

  @DynamicPropertySource
  static void setup(DynamicPropertyRegistry registry) {
    registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers); // コンテナで起動中のKafkaへ接続するための接続情報をプロパティへ設定
  }

  @Test
  void contextLoads() throws InterruptedException {
    Assertions.assertThat(kafka.isRunning()).isTrue();
    kafkaOperations.send(MessageBuilder.withPayload("Hello!").build()); // Kafkaへメッセージを送信

    Message<String> message = messages.poll(10, TimeUnit.SECONDS); // Kafkaから受信したメッセージを取得
    Assertions.assertThat(message.getPayload()).isEqualTo("Hello!");
  }

}
```

> **WARINING:**
>
> Topicの作成は `KafkaTestDemoApplication` の中で行っていますが、プロダクション環境であればアプリケーション起動時にTopicを生成することはないので、別の方法で作成すべきなのですが・・・テスト専用のConfigファイルにBean定義するとエラーになってしまったので、いったん `KafkaTestDemoApplication` に定義している状態です :sweat_smile:

> ```java
@SpringBootApplication
public class KafkaTestDemoApplication {
  // ...
  @Bean
  public NewTopic demoTopic() {
    return TopicBuilder.name("demoTopic").build();
  }
  // ...
}
```

# Cloud Pub/Sub編

メッセージブローカーとしてGCP(Google Cloud Platform)のCloud Pub/Subを使ったアプリケーションのテスト構成は以下のような感じになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117313/eed25bb3-8cfc-b9d3-46e7-8fa5379bd488.png)

* https://github.com/kazuki43zoo/spring-boot-test-demo-with-testcontainers/tree/main/gcp-pubsub-test-demo
* https://www.testcontainers.org/modules/gcloud/#pubsub


```java
package com.example.demo.gcp.pubsub;

import com.google.api.gax.core.NoCredentialsProvider;
import com.google.api.gax.grpc.GrpcTransportChannel;
import com.google.api.gax.rpc.FixedTransportChannelProvider;
import com.google.api.gax.rpc.TransportChannelProvider;
import com.google.cloud.pubsub.v1.SubscriptionAdminClient;
import com.google.cloud.pubsub.v1.SubscriptionAdminSettings;
import com.google.cloud.pubsub.v1.TopicAdminClient;
import com.google.cloud.pubsub.v1.TopicAdminSettings;
import com.google.cloud.spring.pubsub.PubSubAdmin;
import com.google.cloud.spring.pubsub.core.PubSubOperations;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PubSubEmulatorContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;

@SpringBootTest
@Testcontainers
class GcpPubsubTestDemoApplicationTests {

  @Container
  static final PubSubEmulatorContainer pubsub = new PubSubEmulatorContainer(
      DockerImageName.parse("gcr.io/google.com/cloudsdktool/cloud-sdk:316.0.0-emulators")); // Cloud Pub/Sub Emulatorのコンテナを生成

  @Autowired
  PubSubOperations pubSubOperations;

  @Autowired
  BlockingQueue<String> messages;

  @DynamicPropertySource
  static void setup(DynamicPropertyRegistry registry) {
    registry.add("spring.cloud.gcp.pubsub.emulator-host", pubsub::getEmulatorEndpoint); // コンテナで起動中のCloud Pub/Sub Enulatorへ接続するための接続情報をプロパティへ設定
  }

  @BeforeAll
  static void setup() throws Exception {
    ManagedChannel channel =
        ManagedChannelBuilder.forTarget("dns:///" + pubsub.getEmulatorEndpoint())
            .usePlaintext()
            .build();
    TransportChannelProvider channelProvider =
        FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel));

    TopicAdminClient topicAdminClient =
        TopicAdminClient.create(
            TopicAdminSettings.newBuilder()
                .setCredentialsProvider(NoCredentialsProvider.create())
                .setTransportChannelProvider(channelProvider)
                .build());

    SubscriptionAdminClient subscriptionAdminClient =
        SubscriptionAdminClient.create(
            SubscriptionAdminSettings.newBuilder()
                .setTransportChannelProvider(channelProvider)
                .setCredentialsProvider(NoCredentialsProvider.create())
                .build());

    PubSubAdmin admin =
        new PubSubAdmin(() -> "gcp-test", topicAdminClient, subscriptionAdminClient);

    admin.createTopic("demoTopic"); // Topicの生成
    admin.createSubscription("demoTopic-sub", "demoTopic"); // Subscriptionの生成

    admin.close();
    channel.shutdown();
  }

  @Test
  void contextLoads() throws InterruptedException {
    pubSubOperations.publish("demoTopic", "Hello World!"); // Pub/Sub Emulatorへメッセージを送信

    String message = messages.poll(10, TimeUnit.SECONDS); // Pub/Sub Emulatorから受信したメッセージを取得
    Assertions.assertThat(message).isEqualTo("Hello World!");
  }

}
```

# AWS SQS編

AWSで分散非同期メッセージングを実現しようと思うと・・SNS+SQSの組み合わせ？になるのかな〜という（勝手な）イメージですが、本投稿では話をシンプルにするためにSQSを使ったアプリケーションに対するテスト構成を紹介します。具体的には以下のような感じになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117313/c7f05d28-7c51-42d5-9113-dc49fc19beaf.png)

* https://github.com/kazuki43zoo/spring-boot-test-demo-with-testcontainers/tree/main/aws-sqs-test-demo
* https://www.testcontainers.org/modules/localstack/

```java
package com.example.demo.aws.sqs;

import com.amazonaws.services.sqs.AmazonSQS;
import com.amazonaws.services.sqs.AmazonSQSAsync;
import com.amazonaws.services.sqs.AmazonSQSClientBuilder;
import io.awspring.cloud.messaging.core.QueueMessagingTemplate;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.localstack.LocalStackContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;

@SpringBootTest
@Testcontainers
class AwsSqsTestDemoApplicationTests {

  @Container
  static final LocalStackContainer localstack = new LocalStackContainer(DockerImageName.parse("localstack/localstack"))
      .withServices(LocalStackContainer.Service.SQS); // Mock SQSを有効にした状態でLocalStackコンテナ(Emulator)を作成

  @Autowired
  AmazonSQSAsync amazonSQSAsync;

  @Autowired
  BlockingQueue<Message<String>> messages;

  @DynamicPropertySource
  static void setup(DynamicPropertyRegistry registry) {
    AmazonSQS amazonSQS = AmazonSQSClientBuilder.standard()
        .withEndpointConfiguration(localstack.getEndpointConfiguration(LocalStackContainer.Service.SQS))
        .withCredentials(localstack.getDefaultCredentialsProvider())
        .build();
    amazonSQS.createQueue("demoQueue"); // Queueの作成
    // ↓ コンテナで起動中のSQS Emulatorへ接続するための資格情報をプロパティへ設定
    registry.add("cloud.aws.credentials.access-key", localstack::getAccessKey);
    registry.add("cloud.aws.credentials.secret-key", localstack::getSecretKey);
    // ↓ コンテナで起動中のSQS Emulatorへ接続するためのリージョン情報をプロパティへ設定
    registry.add("cloud.aws.region.static", localstack::getRegion);
    // ↓ コンテナで起動中のSQS Emulatorへ接続するための接続情報をプロパティへ設定
    registry.add("cloud.aws.sqs.endpoint", localstack.getEndpointConfiguration(LocalStackContainer.Service.SQS)::getServiceEndpoint);
  }

  @Test
  void contextLoads() throws InterruptedException {
    QueueMessagingTemplate template = new QueueMessagingTemplate(amazonSQSAsync);
    template.send("demoQueue", MessageBuilder.withPayload("Hello World!").build()); // SQS Emulatorへメッセージを送信

    Message<String> message = messages.poll(10, TimeUnit.SECONDS); // SQS Emulatorから受信したメッセージを取得
    Assertions.assertThat(message.getPayload()).isEqualTo("Hello World!");
  }

}
```

# MockServer編

他のサービスが提供しているWeb API(REST API)へアクセスするアプリケーションのテスト構成は以下のような感じになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117313/22508db9-83c1-eadb-abb2-cfc69d77d220.png)

* https://github.com/kazuki43zoo/spring-boot-test-demo-with-testcontainers/tree/main/httpclient-test-demo
* https://www.testcontainers.org/modules/mockserver/

```java
package com.example.demo.httpclient;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockserver.client.MockServerClient;
import org.mockserver.model.HttpRequest;
import org.mockserver.model.HttpResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MockServerContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

@SpringBootTest
@Testcontainers
class HttpclientTestDemoApplicationTests {

  @Container
  static final MockServerContainer mockServer =
      new MockServerContainer(DockerImageName.parse("jamesdbloom/mockserver:mockserver-5.11.2")); // MockServerのコンテナを生成

  MockServerClient mockServerClient = new MockServerClient(mockServer.getHost(), mockServer.getServerPort()); // MockServerへ応答データを設定するためのクライアントコンポーネント

  @DynamicPropertySource
  static void setup(DynamicPropertyRegistry registry) {
    registry.add("api.baseUrl", () -> "http://" + mockServer.getHost() + ":" + mockServer.getServerPort()); // コンテナで起動中のMockServerへ接続するための接続情報をプロパティへ設定
  }

  @Autowired
  HttpclientTestDemoApplication.MyClient myClient;

  @Test
  void contextLoads() {
    mockServerClient
        .when(HttpRequest.request().withPath("/hello"))
        .respond(HttpResponse.response().withBody("Hello!")); // 応答データをMockServerへ設定

    String message = myClient.hello(); // Web APIを呼び出す処理を実行
    Assertions.assertThat(message).isEqualTo("Hello!");
  }

}
```

> **NOTE:**
> Web APIを呼び出す処理は以下のような感じで実装してある

> ```java
@Component
class MyClient {
  final WebClient client;
  MyClient(WebClient.Builder builder, @Value("${api.baseUrl:http://localhost:8080/}") String baseUrl) {
    this.client = builder.baseUrl(baseUrl).build();
  }
  String hello() {
    return client.get()
        .uri("/hello")
        .exchangeToMono(res -> res.bodyToMono(String.class))
        .block();
  }
}
```

# まとめ

Testcontainersをアプリケーションが利用するミドルウェアのセットアップを簡単に行うことができます。が、しかし・・・テストの起動・停止の速度は遅くなるのでその点は意識しておいた方がよいのかな〜と思います。起動・停止の影響を最小限にしたい場合は、Singletonモデルを採用すればよいのかもしれません。

* https://www.testcontainers.org/test_framework_integration/manual_lifecycle_control/#singleton-containers