## Description Follow-up to #25338 (the guides revamp). That PR collapsed most multi-page guide series into single pages but left ~50 guides wrapped in a `<name>/index.md` directory structure alongside guides that are already flat `<name>.md` files — an inconsistent mix. This PR finishes the flattening: - Flattens 50 folder-wrapped guides to `content/guides/<slug>.md` via `git mv` (history preserved). These keep the same URL, since Hugo generates the same permalink for a leaf-bundle `dir/index.md` and a flat `dir.md`. - Moves the last 4 guides' local per-guide image bundles (`genai-leveraging-rag`, `genai-video-bot`, `genai-claude-code-mcp`, `java`) into the shared `content/guides/images/` folder with slug-prefixed filenames, matching the convention already used by most other guides (`databases-1.webp`, `kafka-1.webp`, etc.), and rewrites the in-body image references. - Normalizes `genai-claude-code-mcp`'s non-standard filename (`claude-code-mcp-guide.md`) and capitalized `Images/` folder to match convention. - Promotes `content/guides/frameworks/laravel/index.md` to a top-level `content/guides/laravel.md`, since it was the only guide under `frameworks/` and every other language/framework guide is flat. - Adds `aliases` entries for the two URLs that actually changed (`/guides/frameworks/laravel/` and `/guides/genai-claude-code-mcp/claude-code-mcp-guide/`); every other rename preserves its existing URL. Companion PR establishing this as the documented convention going forward: #25503 ## Validation - `hugo --minify` build via `docker compose run` completes with zero warnings/errors (3174 pages, 1524 aliases — unchanged from before this PR). - Verified both changed-URL guides render at their new path and the old path 301s via the Hugo alias redirect. - Verified every image reference in the 4 migrated guides resolves to an existing file in `content/guides/images/`. - Multi-agent docs review (link-checker, cross-reference-checker, guide-conventions) found zero issues. ## Related issues or tickets Discussed in the team's docs Slack channel following #25338. ## Reviews - [ ] Technical review
14 KiB
title, linkTitle, description, keywords, summary, aliases, params
| title | linkTitle | description | keywords | summary | aliases | params | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Testing AWS service integrations using LocalStack | AWS LocalStack | Learn how to test Spring Cloud AWS applications using LocalStack and Testcontainers. | testcontainers, java, spring boot, testing, aws, localstack, s3, sqs, spring cloud aws | Learn how to create a Spring Boot application with Spring Cloud AWS, then test S3 and SQS integrations using Testcontainers and LocalStack. |
|
|
In this guide, you will learn how to:
- Create a Spring Boot application with Spring Cloud AWS integration
- Use AWS S3 and SQS services
- Test the application using Testcontainers and LocalStack
Prerequisites
- Java 17+
- Maven or Gradle
- A Docker environment supported by Testcontainers
Note
If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.
Create the Spring Boot project
Set up the project
Create a Spring Boot project from Spring Initializr by selecting the Testcontainers starter. Spring Cloud AWS starters are not available on Spring Initializr, so you need to add them manually.
Alternatively, clone the guide repository.
Add the Spring Cloud AWS BOM to your dependency management and add the S3, SQS starters as dependencies. Testcontainers provides a LocalStack module for testing AWS service integrations. You also need Awaitility for testing asynchronous SQS processing.
The key dependencies in pom.xml are:
<properties>
<java.version>17</java.version>
<testcontainers.version>2.0.4</testcontainers.version>
<awspring.version>3.0.3</awspring.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.awspring.cloud</groupId>
<artifactId>spring-cloud-aws-starter-s3</artifactId>
</dependency>
<dependency>
<groupId>io.awspring.cloud</groupId>
<artifactId>spring-cloud-aws-starter-sqs</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-localstack</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.awspring.cloud</groupId>
<artifactId>spring-cloud-aws-dependencies</artifactId>
<version>${awspring.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Create the configuration properties
To make the SQS queue and S3 bucket names configurable, create an
ApplicationProperties record:
package com.testcontainers.demo;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app")
public record ApplicationProperties(String queue, String bucket) {}
Then add @ConfigurationPropertiesScan to the main application class so that
Spring automatically scans for @ConfigurationProperties-annotated classes and
registers them as beans:
package com.testcontainers.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Implement StorageService for S3
Spring Cloud AWS provides higher-level abstractions like S3Template with
convenience methods for uploading and downloading files. Create a
StorageService class:
package com.testcontainers.demo;
import io.awspring.cloud.s3.S3Template;
import java.io.IOException;
import java.io.InputStream;
import org.springframework.stereotype.Service;
@Service
public class StorageService {
private final S3Template s3Template;
public StorageService(S3Template s3Template) {
this.s3Template = s3Template;
}
public void upload(String bucketName, String key, InputStream stream) {
this.s3Template.upload(bucketName, key, stream);
}
public InputStream download(String bucketName, String key)
throws IOException {
return this.s3Template.download(bucketName, key).getInputStream();
}
public String downloadAsString(String bucketName, String key)
throws IOException {
try (InputStream is = this.download(bucketName, key)) {
return new String(is.readAllBytes());
}
}
}
Create the SQS message model
Create a Message record that represents the payload you send to the SQS
queue:
package com.testcontainers.demo;
import java.util.UUID;
public record Message(UUID uuid, String content) {}
Implement the message sender
Create MessageSender, which uses SqsTemplate to publish messages:
package com.testcontainers.demo;
import io.awspring.cloud.sqs.operations.SqsTemplate;
import org.springframework.stereotype.Service;
@Service
public class MessageSender {
private final SqsTemplate sqsTemplate;
public MessageSender(SqsTemplate sqsTemplate) {
this.sqsTemplate = sqsTemplate;
}
public void publish(String queueName, Message message) {
sqsTemplate.send(to -> to.queue(queueName).payload(message));
}
}
Implement the message listener
Create MessageListener with a handler method annotated with @SqsListener.
When a message arrives, the listener uploads the content to an S3 bucket using
the message UUID as the key:
package com.testcontainers.demo;
import io.awspring.cloud.sqs.annotation.SqsListener;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import org.springframework.stereotype.Service;
@Service
public class MessageListener {
private final StorageService storageService;
private final ApplicationProperties properties;
public MessageListener(
StorageService storageService,
ApplicationProperties properties
) {
this.storageService = storageService;
this.properties = properties;
}
@SqsListener(queueNames = { "${app.queue}" })
public void handle(Message message) {
String bucketName = this.properties.bucket();
String key = message.uuid().toString();
ByteArrayInputStream is = new ByteArrayInputStream(
message.content().getBytes(StandardCharsets.UTF_8)
);
this.storageService.upload(bucketName, key, is);
}
}
The ${app.queue} expression reads the queue name from application
configuration instead of hard-coding it.
Write tests with Testcontainers
To test the application, you need a running LocalStack instance that emulates
the AWS S3 and SQS services. Testcontainers spins up LocalStack in a Docker
container and @DynamicPropertySource connects it to Spring Cloud AWS.
Configure the test container
You can start a LocalStack container and configure the Spring Cloud AWS properties to talk to it instead of actual AWS services. The properties you need to set are:
spring.cloud.aws.s3.endpoint=http://localhost:4566
spring.cloud.aws.sqs.endpoint=http://localhost:4566
spring.cloud.aws.credentials.access-key=noop
spring.cloud.aws.credentials.secret-key=noop
spring.cloud.aws.region.static=us-east-1
For testing, use an ephemeral container that starts on a random available port so that you can run multiple builds in CI in parallel without port conflicts.
Write the test
Create MessageListenerTest.java:
package com.testcontainers.demo;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3;
import static org.testcontainers.containers.localstack.LocalStackContainer.Service.SQS;
import java.io.IOException;
import java.time.Duration;
import java.util.UUID;
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.localstack.LocalStackContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
@SpringBootTest
@Testcontainers
class MessageListenerTest {
@Container
static LocalStackContainer localStack = new LocalStackContainer(
DockerImageName.parse("localstack/localstack:3.0")
);
static final String BUCKET_NAME = UUID.randomUUID().toString();
static final String QUEUE_NAME = UUID.randomUUID().toString();
@DynamicPropertySource
static void overrideProperties(DynamicPropertyRegistry registry) {
registry.add("app.bucket", () -> BUCKET_NAME);
registry.add("app.queue", () -> QUEUE_NAME);
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.s3.endpoint",
() -> localStack.getEndpointOverride(S3).toString()
);
registry.add(
"spring.cloud.aws.sqs.endpoint",
() -> localStack.getEndpointOverride(SQS).toString()
);
}
@BeforeAll
static void beforeAll() throws IOException, InterruptedException {
localStack.execInContainer("awslocal", "s3", "mb", "s3://" + BUCKET_NAME);
localStack.execInContainer(
"awslocal",
"sqs",
"create-queue",
"--queue-name",
QUEUE_NAME
);
}
@Autowired
StorageService storageService;
@Autowired
MessageSender publisher;
@Autowired
ApplicationProperties properties;
@Test
void shouldHandleMessageSuccessfully() {
Message message = new Message(UUID.randomUUID(), "Hello World");
publisher.publish(properties.queue(), message);
await()
.pollInterval(Duration.ofSeconds(2))
.atMost(Duration.ofSeconds(10))
.ignoreExceptions()
.untilAsserted(() -> {
String msg = storageService.downloadAsString(
properties.bucket(),
message.uuid().toString()
);
assertThat(msg).isEqualTo("Hello World");
});
}
}
Here's what the test does:
@SpringBootTeststarts the full Spring application context.- The Testcontainers JUnit 5 annotations
@Testcontainersand@Containermanage the lifecycle of aLocalStackContainerinstance. @DynamicPropertySourceobtains the dynamic S3 and SQS endpoint URLs, region, access key, and secret key from the container, and registers them as Spring Cloud AWS configuration properties.@BeforeAllcreates the required SQS queue and S3 bucket using theawslocalCLI tool that comes pre-installed in the LocalStack Docker image. ThelocalStack.execInContainer()API runs commands inside the container.shouldHandleMessageSuccessfully()publishes aMessageto the SQS queue. The listener receives the message and stores its content in the S3 bucket with the UUID as the key. Awaitility waits up to 10 seconds for the expected content to appear in the bucket.
Run tests and next steps
Run the tests
$ ./mvnw test
Or with Gradle:
$ ./gradlew test
You should see the LocalStack Docker container start and the test pass. After the tests finish, the container stops and is removed automatically.
Summary
LocalStack lets you develop and test AWS-based applications locally. The Testcontainers LocalStack module makes it straightforward to write integration tests by using ephemeral LocalStack containers that start on random ports with no external setup required.
To learn more about Testcontainers, visit the Testcontainers overview.