今参画している案件で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のリポジトリとして公開してあります。
MySQL編
データベースとしてMySQLを使ったアプリケーションのテスト構成は以下のような感じになります。
- https://github.com/kazuki43zoo/spring-boot-test-demo-with-testcontainers/tree/main/mysql-test-demo
- https://www.testcontainers.org/modules/databases/mysql/
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を使ったアプリケーションのテスト構成は以下のような感じになります。
- https://github.com/kazuki43zoo/spring-boot-test-demo-with-testcontainers/tree/main/postgresql-test-demo
- https://www.testcontainers.org/modules/databases/postgres/
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を使ったアプリケーションのテスト構成は以下のような感じになります。
- https://github.com/kazuki43zoo/spring-boot-test-demo-with-testcontainers/tree/main/kafka-test-demo
- https://www.testcontainers.org/modules/kafka/
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
に定義している状態です
@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を使ったアプリケーションに対するテスト構成を紹介します。具体的には以下のような感じになります。
- https://github.com/kazuki43zoo/spring-boot-test-demo-with-testcontainers/tree/main/aws-sqs-test-demo
- https://www.testcontainers.org/modules/localstack/
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)へアクセスするアプリケーションのテスト構成は以下のような感じになります。
- https://github.com/kazuki43zoo/spring-boot-test-demo-with-testcontainers/tree/main/httpclient-test-demo
- https://www.testcontainers.org/modules/mockserver/
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を呼び出す処理は以下のような感じで実装してある
@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