1. OVERVIEW

Message brokers and messaging systems are fundamental to building resilient distributed applications.

Both AWS SQS and SNS are messaging systems that facilitate asynchronous communication between different parts of a distributed architecture.

Your organization’s Spring Boot applications use different messaging approaches: certain applications send SNS notifications, others publish messages to SQS queues, while additional applications consume messages from SQS queues or process SNS notifications that have been bridged into SQS.

Along with writing unit tests for REST controllers, business logic, etc., you should consider writing integration tests to verify the interactions between different systems work well.

This blog post helps you write a custom Spring’s TestExecutionListener to automate provisioning SQS queues and SNS topics for Spring Boot integration tests, ensuring each test starts from a known state and can run independently in any order.

Additionally, this blog post covers writing a custom Jupiter/JUnit 5 Extension to share a Testcontainers LocalStack instance across your SNS, SQS-related integration test suite, instead of each integration test class starting a new LocalStack Docker container.

Provision SNS topics and SQS queues for testing Spring Boot applications with Testcontainers, LocalStack and JUnit 5 Extension Provision SNS topics and SQS queues for testing Spring Boot applications with Testcontainers, LocalStack and JUnit 5 Extension

2. MAVEN DEPENDENCIES

pom.xml:

...
<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.5.4</version>
  <relativePath />
</parent>

<properties>
  <java.version>21</java.version>
  <spring-cloud-aws.version>3.4.0</spring-cloud-aws.version>
</properties>

<dependencies>
  <dependency>
    <groupId>io.awspring.cloud</groupId>
    <artifactId>spring-cloud-aws-starter-sqs</artifactId>
  </dependency>

...
  <!-- Test dependencies -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>io.awspring.cloud</groupId>
    <artifactId>spring-cloud-aws-starter-sns</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>localstack</artifactId>
    <scope>test</scope>
  </dependency>
...
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.awspring.cloud</groupId>
      <artifactId>spring-cloud-aws-dependencies</artifactId>
      <version>${spring-cloud-aws.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
  • This Spring Boot application uses version 3.5.4.

  • AWS BOM’s spring-cloud-aws-dependencies is set to 3.4.0.
    This is the most recent version you can use with Spring Boot 3.5.x according to the compatibility table below.

  • spring-cloud-aws-starter-sqs is one of the libraries managed by spring-cloud-aws-dependencies, and it helps you write AWS SQS publishers and listeners for Spring Boot applications.

  • spring-cloud-aws-starter-sns is added as a test dependency because the code snippets discussed in this post are based on a Spring Boot SQS subscriber application.
    The integration tests cover both scenarios: consuming messages published directly to SQS queues and consuming SQS messages routed through SNS subscriptions.
    If your Spring Boot application already includes spring-cloud-aws-starter-sns, you don’t have to add it as a test dependency.

  • org.testcontainers:junit-jupiter brings support for @Testcontainers class-level annotation and the TestcontainersExtension Jupiter/JUnit 5 Extension.

  • org.testcontainers:localstack brings in the LocalStack module allowing you to develop and/or test your Java applications using AWS services like S3, SNS, SQS, and more.

Spring Boot, Spring Cloud AWS, and AWS Java SDK compatibility table

Spring Cloud AWS Spring Boot Spring Cloud AWS Java SDK
3.0.x 3.0.x, 3.1.x 2022.0.x (4.0/Kilburn) 2.x
3.1.x 3.2.x 2023.0.x (4.0/Kilburn) 2.x
3.2.x 3.2.x, 3.3.x 2023.0.x (4.0/Kilburn) 2.x
3.3.x 3.4.x 2024.0.x 2.x
3.4.x 3.5.x 2025.0.x 2.x

Souce: https://github.com/awspring/spring-cloud-aws

3. TESTEXECUTIONLISTENER SUPPORTING CODE

This custom Spring’s TestExecutionListener implementation hooks into the integration test life cycle to provision SQS queues and SNS topics with subscriptions before each test runs, and to delete them after each test completes.

MessagingTestExecutionListener.java:

public class MessagingTestExecutionListener extends AbstractTestExecutionListener {

  @Override
  public void beforeTestMethod(TestContext testContext) throws Exception {
    super.beforeTestMethod(testContext);
    this.executeMessagingOperation(testContext, Messaging.ExecutionPhase.BEFORE_TEST_METHOD);
  }

  @Override
  public void afterTestMethod(TestContext testContext) throws Exception {
    RuntimeException resultingException = null;
    try {
        this.executeMessagingOperation(testContext, Messaging.ExecutionPhase.AFTER_TEST_METHOD);
    } catch (RuntimeException ex) {
        resultingException = ex;
        log.warn("Swallowed exception to continue with the afterTestMethod() execution chain", ex);
    }
    super.afterTestMethod(testContext);
    if (resultingException != null) {
        throw resultingException;
    }
  }

  private void executeMessagingOperation(Set<Messaging> messagingAnnotations, TestContext testContext,
      Messaging.ExecutionPhase executionPhase, boolean classLevel) {

    messagingAnnotations.forEach(messagingAnnotation ->
      this.executeMessagingOperation(messagingAnnotation, testContext, executionPhase, classLevel)
    );
  }

  private void executeMessagingOperation(Messaging messagingAnnotation, TestContext testContext,
      Messaging.ExecutionPhase executionPhase, boolean classLevel) {

// ...

    AwsSnsAttributes snsAttributes = this.initAwsSnsAttributes(messagingAnnotation.sns(), testContext);
    TestingInfrastructurePopulator snsPopulator = snsAttributes != null ? new AwsSnsTopicPopulator(snsAttributes) : null;

    AwsSqsAttributes sqsAttributes = this.initAwsSqsAttributes(messagingAnnotation.sqs(), testContext);
    TestingInfrastructurePopulator sqsPopulator = sqsAttributes != null ? new AwsSqsQueuePopulator(sqsAttributes) : null;

    List<TestingInfrastructurePopulator> populators = new ArrayList<>();
    CollectionUtils.addIgnoreNull(populators, snsPopulator);
    CollectionUtils.addIgnoreNull(populators, sqsPopulator);

    if (messagingAnnotation.executionPhase() == Messaging.ExecutionPhase.BEFORE_TEST_METHOD) {
      populators.forEach(populator -> populator.populate());
    } else {
      populators.forEach(populator -> populator.deleteAll());
    }
  }

// ...
}
  • beforeTestMethod() method first follows the TestExecutionListener chain by executing super.beforeTestMethod(…), then executeMessagingOperation(…).

    afterTestMethod() instead first calls executeMessagingOperation(…), handles possible exceptions, then follows the TestExecutionListener chain by calling super.afterTestMethod(…) for other listeners to cleanup resources, for instance.

    They both execute executeMessagingOperation(…), each passing the BEFORE_TEST_METHOD and AFTER_TEST_METHOD execution phases respectively.
  • Multiple executeMessagingOperation() overloaded methods:
    1. Retrieves and merges @MessagingGroup and @Messaging configuration annotations, prioritizing method-level over class-level configuration as shown in CustomerSubscriberTestcontainersIntegrationTest.

    2. A test class or method can have multiple @Messaging annotations, so it loops through the merged @Messaging annotations and calls another overloaded method passing the test execution phase and each @Messaging annotation.

    3. Uses metadata from the @Messaging configuration annotation to instantiate SNS and SQS configuration objects and delegates provisioning or tearing down the SNS and SQS infrastructure to populator classes discussed next.

4. SQS QUEUE, SNS TOPIC AND SUBSCRIPTION POPULATOR

MessagingTestExecutionListener delegates populating and deleting queues, topics and subscriptions to these classes.

AwsSnsTopicPopulator.java:

public class AwsSnsTopicPopulator implements TestingInfrastructurePopulator {

  private final AwsSnsAttributes snsAttributes;

  @Override
  public void populate() {
      // 1. Create SNS topic
      CreateTopicResponse createTopicResponse = this.createSnsTopic();

      // 2. Create SQS queue
      CreateQueueResponse createQueueResponse = this.createSqsQueue();

      // 3. Get SQS attributes to use QueueArn
      GetQueueAttributesResponse getQueueAttributesResponse = this.retrieveSqsQueueAttributes(createQueueResponse.queueUrl());

      // 4. Subscribe SNS with protocol=sqs, topic-arn with that of 1.topicArn(), and notification-endpoint with that of 3.QueueArn
      this.addSnsSubscription(
        createTopicResponse.topicArn(),
        getQueueAttributesResponse.attributes().get(QueueAttributeName.QUEUE_ARN)
      );
  }

  @Override
  public void deleteAll() {
    this.deleteSnsTopic();
    this.deleteSqsQueue();
  }

  private CreateTopicResponse createSnsTopic() {
    log.info("Creating SNS Topic '{}'", this.snsAttributes.getSnsTopicName());
    CreateTopicRequest request = CreateTopicRequest.builder()
      .name(this.snsAttributes.getSnsTopicName())
      .build();
    CreateTopicResponse result = this.snsAttributes.getSnsClient().createTopic(request);

    log.info("{}", result);
    return result;
  }

  @SneakyThrows
  private CreateQueueResponse createSqsQueue() {
    log.info("Creating SQS Queue '{}'", this.snsAttributes.getSqsAttributes().getSqsQueueName());
    CreateQueueRequest request = CreateQueueRequest.builder()
      .queueName(this.snsAttributes.getSqsAttributes().getSqsQueueName())
      .build();
    CreateQueueResponse result = this.snsAttributes.getSqsAttributes().getSqsClient().createQueue(request).get();

    log.info("{}", result);
    return result;
  }

  @SneakyThrows
  private GetQueueAttributesResponse retrieveSqsQueueAttributes(String sqsQueueUrl) {
    log.info("Getting SQS Queue Attributes for queueUrl '{}'", sqsQueueUrl);
    GetQueueAttributesRequest request = GetQueueAttributesRequest.builder()
      .queueUrl(sqsQueueUrl)
      .attributeNames(QueueAttributeName.QUEUE_ARN)
      .build();
    return this.snsAttributes.getSqsAttributes().getSqsClient().getQueueAttributes(request).get();
  }

  private SubscribeResponse addSnsSubscription(String snsTopicArn, String sqsQueueArn) {
    log.info("Subscribing SNS TopicArn '{}' to SQS Notification-Endpoint QueueArn '{}'", snsTopicArn, sqsQueueArn);
    SubscribeRequest subscribeRequest = SubscribeRequest.builder()
      .protocol("sqs")
      .topicArn(snsTopicArn)
      .endpoint(sqsQueueArn)
      .build();
    return this.snsAttributes.getSnsClient().subscribe(subscribeRequest);
  }

  private void deleteSnsTopic() {
    log.info("Finding TopicArn for SNS Topic '{}'", this.snsAttributes.getSnsTopicName());
    ListTopicsResponse snsTopics = this.snsAttributes.getSnsClient().listTopics();
    Optional<Topic> optSnsTopic = snsTopics.topics().stream()
      .filter(sqsTopic -> sqsTopic.topicArn().endsWith(String.format(":%s", this.snsAttributes.getSnsTopicName())))
      .findFirst();

    optSnsTopic.ifPresentOrElse(
      snsTopic -> {
        log.info("Deleting SNS Topic[name={}, topicArn={}]", this.snsAttributes.getSnsTopicName(), snsTopic.topicArn());
        DeleteTopicRequest request = DeleteTopicRequest.builder()
          .topicArn(snsTopic.topicArn())
          .build();
        this.snsAttributes.getSnsClient().deleteTopic(request);
      },
      () -> log.info("SNS Topic '{}' not found", this.snsAttributes.getSnsTopicName())
    );
  }

  @SneakyThrows
  private void deleteSqsQueue() {
    log.info("Finding QueueUrl for SQS Queue '{}'", this.snsAttributes.getSqsAttributes().getSqsQueueName());
    ListQueuesResponse sqsQueues = this.snsAttributes.getSqsAttributes().getSqsClient().listQueues().get();
    Optional<String> optQueueUrl = sqsQueues.queueUrls().stream()
      .filter(queueUrl -> queueUrl.endsWith(String.format("/%s", this.snsAttributes.getSqsAttributes().getSqsQueueName())))
      .findFirst();

    optQueueUrl.ifPresentOrElse(
      sqsQueueUrl -> {
        log.info("Deleting SQS Queue[name={}, queryUrl={}]", this.snsAttributes.getSqsAttributes().getSqsQueueName(), sqsQueueUrl);
        DeleteQueueRequest request = DeleteQueueRequest.builder()
          .queueUrl(sqsQueueUrl)
          .build();
        this.snsAttributes.getSqsAttributes().getSqsClient().deleteQueue(request);
      },
      () -> log.info("SQS Queue '{}' not found", this.snsAttributes.getSqsAttributes().getSqsQueueName())
    );
  }

}

The AwsSqsQueuePopulator class is similar to the AwsSnsTopicPopulator’s SQS provisioning section, so there’s no need to discuss it.

  • populate() method creates an SNS topic, an SQS queue, and an SQS queue subscription to the SNS topic using AWS Java SDK version 2 for the integration tests to use.

  • deleteAll() method deletes the SNS and SQS infrastructure for the next test, if any, to start from scratch, from a known state.
    Alternatively, it could have simply emptied the queues and topics.

5. TEST CONFIGURATION

src/test/resources/application-integration-test.yml:

spring:
  cloud:
    aws:
      region:
        static: localstack
      credentials:
        accessKey: testAccessKey
        secretKey: testSecretKey
      sns:
        endpoint: http://localhost:4566/
      sqs:
        endpoint: http://localhost:4566/

Not much to discuss about these configuration properties.
But it’s worth mentioning these will be updated at runtime via the @DynamicPropertySource annotation, or in the custom Jupiter/JUnit 5 extension.

6. INTEGRATION TEST CLASS (version 1)

Let’s write a Spring Boot SQS subscriber integration test class that uses everything laid out in this post.

CustomerSubscriberTestcontainersIntegrationTest.java:

@ExtendWith({ SpringExtension.class, OutputCaptureExtension.class })
@SpringBootTest(classes = Application.class)
@ContextConfiguration(classes = { Application.class })
@ActiveProfiles("integration-test")
@TestExecutionListeners( { DependencyInjectionTestExecutionListener.class, MessagingTestExecutionListener.class })
@MessagingGroup({
  // No need to provision SNS (topic, subscription)
  @Messaging(
    sqs = @Sqs(queueName = "queue-customers-sqs-test", sqsClient = "sqsAsyncClient"),
    executionPhase = Messaging.ExecutionPhase.BEFORE_TEST_METHOD
  ),
  @Messaging(
    sqs = @Sqs(queueName = "queue-customers-sqs-test"),
    executionPhase = Messaging.ExecutionPhase.AFTER_TEST_METHOD
  )
})
@Testcontainers
@Slf4j
public class CustomerSubscriberTestcontainersIntegrationTest {

  @Container
  private static final LocalStackContainer LOCALSTACK = new LocalStackContainer(
    DockerImageName.parse("localstack/localstack:4.6"))
    .withServices(LocalStackContainer.Service.SNS, LocalStackContainer.Service.SQS)
    .withEnv("SQS_ENDPOINT_STRATEGY", "dynamic")
    .waitingFor(Wait.forHttp("/_localstack/init")
      .forResponsePredicate(response -> response.contains("\"READY\": true"))
      .withStartupTimeout(Duration.ofSeconds(20))
    );

// ...

  @DynamicPropertySource
  static void overrideConfiguration(DynamicPropertyRegistry registry) {
    registry.add("spring.cloud.aws.region.static", () -> LOCALSTACK.getRegion());
    registry.add("spring.cloud.aws.credentials.access-key", () -> LOCALSTACK.getAccessKey());
    registry.add("spring.cloud.aws.credentials.secret-key", () -> LOCALSTACK.getSecretKey());
    registry.add("spring.cloud.aws.sns.endpoint", () -> LOCALSTACK.getEndpointOverride(LocalStackContainer.Service.SNS));
    registry.add("spring.cloud.aws.sqs.endpoint", () -> LOCALSTACK.getEndpointOverride(LocalStackContainer.Service.SQS));
  }

  @SneakyThrows
  @Test
  @MessagingGroup({
    // Now also provisioning SNS (topic, queue, subscription)
    @Messaging(
      sns = @Sns(topicName = "topic-customer-creation-events-test", snsClient = "snsClient",
        sqs = @Sqs(queueName = "queue-customers-sns-test", sqsClient = "sqsAsyncClient")
      ),
      sqs = @Sqs(queueName = "queue-customers-sqs-test", sqsClient = "sqsAsyncClient"),
      executionPhase = Messaging.ExecutionPhase.BEFORE_TEST_METHOD
    ),
    @Messaging(
      sns = @Sns(topicName = "topic-customer-creation-events-test",
        sqs = @Sqs(queueName = "queue-customers-sns-test")
      ),
      sqs = @Sqs(queueName = "queue-customers-sqs-test"),
      executionPhase = Messaging.ExecutionPhase.AFTER_TEST_METHOD
    )
  })
  public void shouldConsumeSnsNewCustomerNotification(CapturedOutput output) {
    // Given
    String strCustomer = FileUtils.readFileToString(
      new File(CustomerSubscriberIntegrationTest.class.getClassLoader()
        .getResource("stubs/new-customer-request.json").getFile()
      ),
      "UTF-8");

    // When
    this.snsTemplate.convertAndSend(this.snsCustomerTopicArn, strCustomer);

    // Then
    Awaitility.await().atMost(2, TimeUnit.SECONDS).until(() ->
      output.getOut().contains(
        "Received SNS message payload: NewCustomerEvent(id=3, firstName=Blah_3, lastName=Meh_3, " +
        "emailAddress=invalid.3@asimiotech.com, phone=Phone(number=111-111-1111, type=MOBILE), " +
        "mailingAddress=Address(street=1 Main St, suite=null, city=Orlando, state=FL, zipcode=32801))"
      )
    );
    // More assertions
  }

// ...
}
  • The integration test class uses JUnit 5, and for simplicity it includes only one test method.

  • @ActiveProfiles annotation includes the integration-test profile, causing the tests to use the application-integration-test.yml configuration file.

  • @TestExecutionListeners annotation includes a MessagingTestExecutionListener, a custom Spring’s TestExecutionListener that is used in conjunction with the @MessagingGroup and @Messaging annotations to setup the AWS messaging infrastructure for each integration test method to use.

  • shouldConsumeSnsNewCustomerNotification() test method includes @MessagingGroup and @Messaging configuration annotations.
    MessagingTestExecutionListener supports using the @MessagingGroup and @Messaging annotations at class or method level, prioritizing method-level annotations.

  • @Testcontainers and @Container annotations handle the discovery, setup, and teardown of Docker containers that integration tests use.
    • Uses the LocalStackContainer enabling SQS and SNS services.

    • Overrides the wait strategy until a request to /_localstack/init returns text containing "READY": true for up to 20 seconds.

    • Exposes the LocalStack container port 4566 to a random host port.

    • Sets the environment variable SQS_ENDPOINT_STRATEGY to dynamic.
      It impacts how LocalStack constructs URLs.
      When LocalStack creates an SQS queue and returns the URL, it uses the host port instead of the container port for your Spring Boot application to send requests to.
      Our integration tests log:
      Getting SQS Queue Attributes for queueUrl 'http://localhost:32769/queue/us-east-1/000000000000/queue-customers-sns-test'
      Notice how it uses port 32769 instead of 4566.

  • Lastly, @DynamicPropertySource updates Spring configuration properties at runtime, like SNS and SQS endpoints host and port found in application-integration-test.yml.

Provision SNS topics and SQS queues for testing Spring Boot applications with Testcontainers and LocalStack Provision SNS topics and SQS queues for testing Spring Boot applications with Testcontainers and LocalStack

What if there are multiple integration test classes depending on a LocalStack Docker container?
Using the same @Testcontainers, @Container instantiation approach as CustomerSubscriberTestcontainersIntegrationTest will result in each integration test class spawning its own Docker container, leading to multiple container lifecycles.

The next section provides an optimization approach for reusing Docker containers in your integration test suite.

7. AwsSqsSnsContainerBootstrap JUPITER/JUNIT 5 EXTENSION

Reusing Docker containers in your integration tests results in using resources more efficiently, fewer containers running concurrently, less I/O, less memory overhead, shorter build time, faster build feedback, among other benefits.

Based on a blog post about reusing Testcontainers with JUnit 5 Extension Callbacks in Spring Boot Integration Tests, let’s write a JUnit 5 Extension that handles the LocalStack Docker container lifecycle management and shares it across the test suite.

AwsSqsSnsContainerBootstrapExtension.java:

public class AwsSqsSnsContainerBootstrapExtension implements BeforeAllCallback {

  private static final AtomicBoolean IS_INITIALIZED = new AtomicBoolean(false);
  private static final String LOCALSTACK_IMAGE_NAME = "localstack/localstack:4.6";
  private static final DockerImageName LOCALSTACK_IMAGE = DockerImageName.parse(LOCALSTACK_IMAGE_NAME);

  private LocalStackContainer localStackContainer;

  @Override
  public void beforeAll(ExtensionContext context) throws Exception {

    // Sets up, starts Docker container, system properties
    if (IS_INITIALIZED.compareAndSet(false, true)) {
      this.localStackContainer = new LocalStackContainer(LOCALSTACK_IMAGE)
        .withServices(LocalStackContainer.Service.SNS, LocalStackContainer.Service.SQS)
        .withEnv("SQS_ENDPOINT_STRATEGY", "dynamic")
        .waitingFor(Wait.forHttp("/_localstack/init")
          .forResponsePredicate(response -> response.contains("\"READY\": true"))
          .withStartupTimeout(Duration.ofSeconds(20))
        );
      this.localStackContainer.start();

      this.addSpringCloudAwsProperties();
    }
  }

  private void addSpringCloudAwsProperties() {
    // No need for @DynamicPropertySource-spring.cloud.aws.<aws-service>.endpoint, ...

    String region = this.localStackContainer.getRegion();
    String snsEndpointOverride = this.localStackContainer.getEndpointOverride(LocalStackContainer.Service.SNS).toString();
    String sqsEndpointOverride = this.localStackContainer.getEndpointOverride(LocalStackContainer.Service.SQS).toString();

    log.info("Overriding AWS config properties [region={} snsEndpoint={}, sqsEndpoint={}]",
      region, snsEndpointOverride, sqsEndpointOverride
    );

    System.setProperty("spring.cloud.aws.region.static", region);
    System.setProperty("spring.cloud.aws.sns.endpoint", snsEndpointOverride);
    System.setProperty("spring.cloud.aws.sqs.endpoint", sqsEndpointOverride);
  }

}

The JUnit 5 BeforeAllCallback.beforeAll() method implementation relies on the IS_INITIALIZED AtomicBoolean flag to ensure the if block executes only once.

This guarantees setting up and starting the LocalStack Docker container and updating spring.cloud.aws.* test properties a single time across all tests.

8. INTEGRATION TEST CLASS (version 2)

Let’s now use this extension in our integration test classes.

CustomerSubscriberTestcontainersJUnit5ExtensionIntegrationTest.java:

-@ExtendWith({ SpringExtension.class, OutputCaptureExtension.class })
+@ExtendWith({ AwsSqsSnsContainerBootstrapExtension.class, SpringExtension.class, OutputCaptureExtension.class })
// ...
-@Testcontainers
public class CustomerSubscriberTestcontainersJUnit5ExtensionIntegrationTest {


-  @Container
-  private static final LocalStackContainer LOCALSTACK = new LocalStackContainer(
-    DockerImageName.parse("localstack/localstack:4.6"))
-    .withServices(LocalStackContainer.Service.SNS, LocalStackContainer.Service.SQS)
-    .withEnv("SQS_ENDPOINT_STRATEGY", "dynamic")
-    .waitingFor(Wait.forHttp("/_localstack/init")
-      .forResponsePredicate(response -> response.contains("\"READY\": true"))
-      .withStartupTimeout(Duration.ofSeconds(20))
-    );


-  @DynamicPropertySource
-  static void overrideConfiguration(DynamicPropertyRegistry registry) {
-    registry.add("spring.cloud.aws.region.static", () -> LOCALSTACK.getRegion());
-    registry.add("spring.cloud.aws.credentials.access-key", () -> LOCALSTACK.getAccessKey());
-    registry.add("spring.cloud.aws.credentials.secret-key", () -> LOCALSTACK.getSecretKey());
-    registry.add("spring.cloud.aws.sns.endpoint", () -> LOCALSTACK.getEndpointOverride(LocalStackContainer.Service.SNS));
-    registry.add("spring.cloud.aws.sqs.endpoint", () -> LOCALSTACK.getEndpointOverride(LocalStackContainer.Service.SQS));
-  }

// ...
}

We added the new JUnit 5 Extension AwsSqsSnsContainerBootstrapExtension.class to the @ExtendWith annotation, and this allows us to eliminate the previous @Testcontainers, @Container instantiation, and @DynamicPropertySource usage for runtime Spring property updates.

Let’s run the two integration test classes that use the new extension from command line:

mvn -Dtest=CustomerSubscriberTestcontainersJUnit5ExtensionIntegrationTest,DuplicateCustomerSubscriberTestcontainersJUnit5ExtensionIntegrationTest verify
...
13:09:05.543 [main] INFO com.asimiotech.demo.test.testcontainers.AwsSqsSnsContainerBootstrapExtension -- Overriding AWS config properties [region=us-east-1 snsEndpoint=http://192.168.1.188:32769, sqsEndpoint=http://192.168.1.188:32769]
...
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 10.54 s -- in com.asimiotech.demo.messaging.customer.CustomerSubscriberTestcontainersJUnit5ExtensionIntegrationTest
...
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.126 s -- in com.asimiotech.demo.messaging.customer.DuplicateCustomerSubscriberTestcontainersJUnit5ExtensionIntegrationTest
[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
...

The test suite with four tests now starts a single LocalStack container, which could be confirmed with the log Overriding AWS config properties [region=us-east-1 snsEndpoint=.

9. CONCLUSION

This blog post covered writing integration tests for Spring Boot, SQS, SNS applications with Testcontainers, LocalStack and Docker.

It helped you provision the SQS queues and SNS topics for the Spring Boot integration tests to publish to, and to consume from.

It also included a custom Spring’s TestExecutionListener that hooks into the integration test life cycle to set up and tear down the SQS and SNS infrastructure before and after each Spring Boot integration test.

Finally, you’ve also learned how to create a custom JUnit 5 Extension that enables sharing a Testcontainers LocalStack instance across all your SNS and SQS integration tests, rather than having each test class spin up a separate LocalStack Docker container.

Thanks for reading and as always, feedback is very much appreciated. If you found this post helpful and would like to receive updates when content like this gets published, sign up to the newsletter.

10. SOURCE CODE

Your organization can now save time and costs by purchasing a working code base, clean implementation, with support for future revisions.