Similar to a REST API, an Amazon SQS listener is an entry point to our application. While we can easily test our Spring Web MVC controller endpoints either with MockMvc or the WebTestClient, things are different for our queue listener. Fortunately, with the recent versions of Spring Cloud AWS and thanks to Testcontainers & LocalStack, testing our Amazon SQS listener is a breeze. With the help of @SqsTest, we get a new sliced test annotation that lets us test our Amazon SQS listeners in isolation.
Spring Cloud AWS Project Setup for Testing
Let's start with the project setup for this testing recipe for Amazon SQS listener with Spring Cloud AWS and @SqsTest.
We're using a skeleton Spring Boot project with Java 17 and Maven.
Apart from the basic Spring Boot Starter dependencies for Web and Spring Data JPA, we add Spring Cloud AWS dependencies:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.0</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>de.rieckpil</groupId> <artifactId>spring-cloud-aws-sqs-testing</artifactId> <version>0.0.1-SNAPSHOT</version> <name>spring-cloud-aws-sqs-testing</name> <description>spring-cloud-aws-sqs-testing</description> <properties> <java.version>17</java.version> <spring-cloud-aws.version>2.4.1</spring-cloud-aws.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>io.awspring.cloud</groupId> <artifactId>spring-cloud-starter-aws-messaging</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.awaitility</groupId> <artifactId>awaitility</artifactId> <version>${awaitility.version}</version> <scope>test</scope> </dependency> </dependencies> <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> <!-- Default Spring Boot build section --> </project> |
We'll enrich this pom.xml
with the upcoming sections of this article, but for now, only require the Spring Boot Starter Test (aka. the swiss-army knife of testing) and Awaitility.
As this article is all about testing an Amazon SQS listener, let's write a basic SQS listener:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | @Component public class OrderListener { private final PurchaseOrderRepository purchaseOrderRepository; private static final Logger LOG = LoggerFactory.getLogger(OrderListener.class); public OrderListener(PurchaseOrderRepository purchaseOrderRepository) { this.purchaseOrderRepository = purchaseOrderRepository; } @SqsListener("${order-queue-name}") public void processOrder(@Payload ObjectNode payload, @Headers Map<String, Object> payloadHeaders) { // ... } } |
Thanks to Spring Boot's auto-configuration and Spring Cloud AWS, we only need to add the annotation @SqsListener
to a method to start listening for incoming messages. In the background, Spring Cloud AWS will serialize the incoming message (JSON) to our Java object.
We outsource the queue name configuration so that we can potentially override it per stage or when testing.
The listener itself gets access to the message payload and headers to perform some business operations:
1 2 3 4 5 6 7 8 9 10 11 | @SqsListener("${order-queue-name}") public void processOrder(@Payload ObjectNode payload, @Headers Map<String, Object> payloadHeaders) { LOG.info("Incoming order payload {} with headers {}", payload, payloadHeaders); PurchaseOrder purchaseOrder = new PurchaseOrder(); purchaseOrder.setCustomer(payload.get("customer_name").asText()); purchaseOrder.setAmount(payload.get("order_amount").asLong()); purchaseOrder.setDelivered(false); purchaseOrderRepository.save(purchaseOrder); } |
Our Spring Cloud AWS SQS listener processes incoming order information and stores it in our database for demonstration purposes and to keep things simple.
We don't configure any region or secret for AWS, as we're not deploying the Spring Boot project to AWS and instead focus on the testing part.
Introducing LocalStack as a local AWS Cloud Mock
Integrating various services of our cloud provider of choice helps us stay productive and move fast. Within a matter of seconds, we can request a database instance, a message queue, or blob storage. There's no need for us to host all those infrastructure components.
When it comes to testing our application, we have to find a solution for those parts of our application that integrate cloud services. While we should test our application end-to-end on a staging environment with a similar configuration setup as for production, we may want to use a different approach for our integration tests.
Our aim should be to detect potential bugs as early as possible within our development lifecycle.
Fortunately, there's an excellent tool out there that lets us start a local AWS cloud mock: LocalStack!
LocalStack simplifies testing and local development for any application that interacts with AWS.
While there are many ways to start a LocalStack instance locally, using a Docker container feels most convenient for our use case. Once the instance is up and running, we get access to various AWS services.
We still have to keep in mind that LocalStack emulates the AWS services. While it tries to aim for feature parity with AWS, it clearly can't achieve this for all AWS features. However, for the basic AWS services like SQS, SNS, or S3, it's doing a great job.
For more in-depth information on LocalStack, check out the 30 Testing Tools and Libraries Every Java Developer Must Know ebook.
The best is yet to come.
There's a Testcontainers module available for LocalStack that makes the integration a breeze:
1 2 3 4 5 | <dependency> <groupId>org.testcontainers</groupId> <artifactId>localstack</artifactId> <scope>test</scope> </dependency> |
The version is managed by the Testcontainers Bill of Materials (BOM):
1 2 3 4 5 6 7 8 9 10 11 | <dependencyManagement> <dependencies> <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers-bom</artifactId> <version>${testcontainers.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> |
That's everything we need from an infrastructure perspective to start writing our Amazon SQS listener test.
Using LocalStack with Testcontainers
The Testcontainers LocalStack module gives us access to the LocalStackContainer
. This custom container definition brings many convenient methods for working with LocalStack for our Spring Cloud AWS SQS listener test case.
Let's start with the infrastructure setup for our upcoming listener test:
1 2 3 4 5 6 7 8 9 10 11 12 | @Testcontainers class OrderListenerTest { @Container static LocalStackContainer localStack = new LocalStackContainer(DockerImageName.parse("localstack/localstack:0.14.3")) .withClasspathResourceMapping("/localstack", "/docker-entrypoint-initaws.d", BindMode.READ_ONLY) .withServices(Service.SQS) .waitingFor(Wait.forLogMessage(".*Initialized\\.\n", 1)); // ... } |
We're defining the LocalStackContainer
as a static field and use the Testcontainers JUnit Jupiter extension (activated by @Testcontainers
), to manage the lifecycle of the container.
We override the default Docker image as part of the constructor to use a recent version of LocalStack that comes with multi-region support and even works on an Apple M1.
When starting a fresh LocalStack instance as a Docker container, there won't be any queues, buckets, or topics available. We first have to set them up.
That's where the withClasspathResourceMapping
comes into play. This Testcontainers feature allows us to map a file or folder from our classpath into the container.
The LocalStack Docker container, by default, executes all shell scripts that are part of the /docker-entrypoint-initaws.d
folder right after the initialization.
Hence we map our local classpath resources inside the folder and define what infrastructure we require. In our example, that's an Amazon SQS queue:
1 2 3 4 5 | #!/bin/sh awslocal sqs create-queue --queue-name test-order-queue echo "Initialized." |
The final echo
inside this script helps us identify when the LocalStack container is initialized and set up. Using a custom Testcontainers wait strategy (waitingFor(Wait.forLogMessage(".*Initialized\\.\n", 1))
), we wait for this echo and only proceed with our tests once this appears inside the stdout
of the container.
If we omit this additional wait strategy, we may start our tests too early and try to connect to resources that have not been created yet.
While this was one potential way of initializing our LocalStack instance, there are many other possible ways of initializing a container with Testcontainers.
Testing Setup for @SqsTest and Spring Cloud AWS
The test support from Spring Cloud AWS comes with a dedicated dependency that we have to include first:
1 2 3 4 5 | <dependency> <groupId>io.awspring.cloud</groupId> <artifactId>spring-cloud-aws-test</artifactId> <scope>test</scope> </dependency> |
Similarly to our spring-cloud-starter-messaging
dependency declaration, we omit the version as we're using the awspring
Maven BOM.
As we're using Testcontainers to start our LocalStack instance, the services will run inside a Docker container on an ephemeral port. This port changes with each test invocation; hence, we have to override where our Spring Boot application should connect dynamically.
Furthermore, we have to override our Spring Cloud AWS configuration to not connect to the real AWS cloud. Fortunately, this is a simple task with recent versions of Spring Cloud AWS.
To only start a subset of our ApplicationContext
and test our SQS listener in isolation, we use the slice test annotation @SqsTest
. We can optionally pass the class name of our Spring Cloud AWS SQS listener we want to test.
This will narrow down our sliced context and only include this listener. Otherwise, Spring Boot will try to populate all listener beans (similar behavior as @WebMvcTest):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | @Testcontainers @SqsTest(OrderListener.class) class OrderListenerTest { @Container // ... LocalStack container definition as seen above @DynamicPropertySource static void properties(DynamicPropertyRegistry registry) { registry.add("cloud.aws.sqs.endpoint", () -> localStack.getEndpointOverride(SQS).toString()); registry.add("cloud.aws.credentials.access-key", () -> "foo"); registry.add("cloud.aws.credentials.secret-key", () -> "bar"); registry.add("cloud.aws.region.static", () -> localStack.getRegion()); registry.add("order-queue-name", () -> "test-order-queue"); } // ... } |
The most essential part of this testing recipe is overriding Spring Cloud AWS's connection parameters. We don't want any interaction with the real AWS cloud during our tests.
The @DynamicPropertySource
makes these property overrides handy as we can dynamically define properties. We do so and override the SQS endpoint (the HTTP API), our AWS credentials, and region configuration.
While we could potentially hardcode the credentials, region, and queue name in a property file, we can't do this for the endpoint. Testcontainers maps the container port to a random and ephemeral port; therefore, this property changes with each test invocation.
With the endpoint configuration, we tell Spring Cloud AWS where to find the AWS APIs. When running in production on AWS, these are the real AWS API. Locally, we're connecting to LocalStack.
Integration Testing our Amazon SQS Listener
Finally, we can write the actual test that verifies our listener can process incoming orders:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | @Autowired private QueueMessagingTemplate queueMessagingTemplate; @MockBean private PurchaseOrderRepository purchaseOrderRepository; @Test void shouldStoreIncomingPurchaseOrderInDatabase() { Map<String, Object> messageHeaders = Map.of("contentType", "application/json"); String payload = """ { "customer_name": "duke", "order_amount": 42 } """; queueMessagingTemplate .send("test-order-queue", new GenericMessage<>(payload, messageHeaders)); await() .atMost(Duration.ofSeconds(3)) .untilAsserted(() -> verify(purchaseOrderRepository).save(any(PurchaseOrder.class))); } |
The @SqsTest
annotation focuses on testing listener classes and hence no persistence or web-related beans are part of this context. As our listener depends on the PurchaseOrderRepository (a Spring Data JPA repository), we have to either manually provide this bean or use @MockBean
(not to be confused with @Mock) to place a mocked version of this bean into our context.
We trigger the actual listener message handling by sending a message to our SQS queue. While there are multiple ways to send a message (e.g., using the AWS SDK, awslocal
inside LocalStack, etc.), we use the most convenient solution and inject the QueueMessagingTemplate
to our test.
This messaging template is part of this slice context and auto-configured accordingly to point to our LocalStack instance.
What's left is to verify our business operation. In our example, we verify the save()
method of our Spring Data JPA repository was invoked.
As we're testing an asynchronous process, we can't immediately assume the interaction happened after sending the message. That's why we wrap our verification with Awaitility and expect the interaction within three seconds.
That's it for the recipe – simple, isn't it?
Summary: Testing our Amazon SQS Listener with @SqsTest
The continuous development efforts for Spring Cloud AWS simplify the way we can test our Spring Boot applications. In combination with LocalStack and Testcontainers, we have tools at hand that makes testing these parts of our application joyful.
In short, this recipe boils down to:
- Use
@SqsTest
to test the Amazon SQS listener in isolation - Start a LocalStack instance with Testcontainers
- Ensure all required infrastructure components are initialized
- Override the Spring Cloud AWS settings with
@DynamicPropertySource
- Use Awaitility when testing asynchronous operations
You can find further hands-on Java-related AWS articles here:
- Java AWS Lambda Example with Serverless and Maven
- Resolving Spring Boot Properties Using the AWS Parameter Store (SSM)
- Java AWS Lambda Container Image Support (Complete Guide)
For those of you that want detailed guidance and recommendation on how to develop and deploy their Spring Boot application with AWS, make sure to check out Stratospheric.
The source code is available on GitHub.
Joyful testing,
Philip