LocalStack — Testing AWS Services (S3, SQS, SNS) with Testcontainers
Testing AWS integrations against real AWS services requires IAM credentials, costs money per test run, and creates real side effects (files in S3, messages in SQS). LocalStack runs a local AWS cloud emulator in Docker — the same API, the same response format, but running locally with no AWS account required. This article covers testing S3, SQS, and SNS integrations with LocalStackContainer.
What You’ll Learn
LocalStackContainersetup and@ServiceConnection- Creating S3 buckets and testing file upload/download
- Testing SQS message producers and
@SqsListenerconsumers - Testing SNS to SQS subscriptions
- Creating AWS resources before tests with the AWS SDK
- Testing DynamoDB operations
Dependencies
<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>io.awspring.cloud</groupId>
<artifactId>spring-cloud-aws-starter-sns</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>localstack</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
LocalStackContainer Setup
@SpringBootTest
@Testcontainers
class AwsIntegrationTest {
@Container
static LocalStackContainer localstack = new LocalStackContainer(
DockerImageName.parse("localstack/localstack:3.4.0")
).withServices(
LocalStackContainer.Service.S3,
LocalStackContainer.Service.SQS,
LocalStackContainer.Service.SNS
);
@DynamicPropertySource
static void configureAws(DynamicPropertyRegistry registry) {
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.region.static", localstack::getRegion);
registry.add("spring.cloud.aws.s3.endpoint",
() -> localstack.getEndpointOverride(LocalStackContainer.Service.S3).toString());
registry.add("spring.cloud.aws.sqs.endpoint",
() -> localstack.getEndpointOverride(LocalStackContainer.Service.SQS).toString());
registry.add("spring.cloud.aws.sns.endpoint",
() -> localstack.getEndpointOverride(LocalStackContainer.Service.SNS).toString());
}
}
Spring Boot 3.1+ with @ServiceConnection
@Container
@ServiceConnection
static LocalStackContainer localstack = new LocalStackContainer(
DockerImageName.parse("localstack/localstack:3.4.0")
).withServices(
LocalStackContainer.Service.S3,
LocalStackContainer.Service.SQS,
LocalStackContainer.Service.SNS
);
Testing S3 Uploads and Downloads
The S3 Service
@Service
public class OrderDocumentService {
private final S3Template s3Template;
private static final String BUCKET = "order-documents";
public OrderDocumentService(S3Template s3Template) {
this.s3Template = s3Template;
}
public String uploadOrderConfirmation(String orderId, String content) {
String key = "confirmations/" + orderId + ".txt";
s3Template.upload(BUCKET, key, new ByteArrayInputStream(content.getBytes()));
return key;
}
public String downloadOrderConfirmation(String orderId) {
String key = "confirmations/" + orderId + ".txt";
S3Resource resource = s3Template.download(BUCKET, key);
try {
return new String(resource.getInputStream().readAllBytes());
} catch (IOException e) {
throw new DocumentNotFoundException(orderId, e);
}
}
public boolean confirmationExists(String orderId) {
String key = "confirmations/" + orderId + ".txt";
return s3Template.objectExists(BUCKET, key);
}
}
S3 Tests
@SpringBootTest
@Testcontainers
class OrderDocumentServiceTest {
@Container
@ServiceConnection
static LocalStackContainer localstack = new LocalStackContainer(
DockerImageName.parse("localstack/localstack:3.4.0")
).withServices(LocalStackContainer.Service.S3);
@Autowired
private OrderDocumentService documentService;
@Autowired
private S3Client s3Client;
@BeforeAll
static void createBucket(@Autowired S3Client s3Client) {
s3Client.createBucket(b -> b.bucket("order-documents"));
}
@AfterEach
void cleanBucket() {
ListObjectsV2Response objects = s3Client.listObjectsV2(
r -> r.bucket("order-documents")
);
objects.contents().forEach(obj ->
s3Client.deleteObject(r -> r.bucket("order-documents").key(obj.key()))
);
}
@Test
void shouldUploadAndDownloadOrderConfirmation() {
String content = "Order order-123 confirmed. Total: $99.99";
String key = documentService.uploadOrderConfirmation("order-123", content);
assertThat(key).isEqualTo("confirmations/order-123.txt");
assertThat(documentService.confirmationExists("order-123")).isTrue();
String downloaded = documentService.downloadOrderConfirmation("order-123");
assertThat(downloaded).isEqualTo(content);
}
@Test
void shouldReturnFalseForNonExistentDocument() {
assertThat(documentService.confirmationExists("nonexistent-order")).isFalse();
}
@Test
void shouldThrowWhenDownloadingNonExistentDocument() {
assertThatThrownBy(() -> documentService.downloadOrderConfirmation("missing-order"))
.isInstanceOf(DocumentNotFoundException.class);
}
}
Testing SQS Producers and Consumers
The Order Notification Service
@Service
public class OrderNotificationService {
private final SqsTemplate sqsTemplate;
private static final String QUEUE = "order-notifications";
public OrderNotificationService(SqsTemplate sqsTemplate) {
this.sqsTemplate = sqsTemplate;
}
public void sendOrderNotification(OrderNotification notification) {
sqsTemplate.send(QUEUE, notification);
}
}
@Service
public class OrderNotificationConsumer {
private final List<OrderNotification> receivedNotifications = new CopyOnWriteArrayList<>();
@SqsListener("order-notifications")
public void processNotification(OrderNotification notification) {
receivedNotifications.add(notification);
}
public List<OrderNotification> getReceivedNotifications() {
return Collections.unmodifiableList(receivedNotifications);
}
}
SQS Tests
@SpringBootTest
@Testcontainers
class OrderNotificationServiceTest {
@Container
@ServiceConnection
static LocalStackContainer localstack = new LocalStackContainer(
DockerImageName.parse("localstack/localstack:3.4.0")
).withServices(LocalStackContainer.Service.SQS);
@Autowired
private OrderNotificationService notificationService;
@Autowired
private OrderNotificationConsumer notificationConsumer;
@Autowired
private SqsAsyncClient sqsClient;
@BeforeAll
static void createQueue(@Autowired SqsAsyncClient sqsClient) throws Exception {
sqsClient.createQueue(r -> r.queueName("order-notifications")).get();
}
@Test
void shouldSendAndReceiveOrderNotification() {
OrderNotification notification = new OrderNotification(
"order-1", "customer-1", "ORDER_CONFIRMED",
"Your order has been confirmed"
);
notificationService.sendOrderNotification(notification);
await()
.atMost(Duration.ofSeconds(15))
.until(() -> notificationConsumer.getReceivedNotifications().stream()
.anyMatch(n -> n.orderId().equals("order-1"))
);
assertThat(notificationConsumer.getReceivedNotifications())
.extracting(OrderNotification::orderId)
.contains("order-1");
}
@Test
void shouldProcessMultipleNotifications() {
List<OrderNotification> notifications = List.of(
new OrderNotification("order-1", "customer-1", "CONFIRMED", "Confirmed"),
new OrderNotification("order-2", "customer-2", "SHIPPED", "Shipped"),
new OrderNotification("order-3", "customer-3", "DELIVERED", "Delivered")
);
notifications.forEach(notificationService::sendOrderNotification);
await()
.atMost(Duration.ofSeconds(20))
.until(() -> notificationConsumer.getReceivedNotifications().size() >= 3);
assertThat(notificationConsumer.getReceivedNotifications())
.extracting(OrderNotification::orderId)
.containsExactlyInAnyOrder("order-1", "order-2", "order-3");
}
}
Testing SNS to SQS Fan-Out
SNS publishes to multiple SQS queues simultaneously. Test the fan-out:
@BeforeAll
static void setupSnsToSqsFanout(
@Autowired SnsAsyncClient snsClient,
@Autowired SqsAsyncClient sqsClient
) throws Exception {
// Create queues
sqsClient.createQueue(r -> r.queueName("order-email-notifications")).get();
sqsClient.createQueue(r -> r.queueName("order-push-notifications")).get();
// Create SNS topic
CreateTopicResponse topic = snsClient.createTopic(r -> r.name("order-events")).get();
String topicArn = topic.topicArn();
// Get queue ARNs and subscribe them to the topic
String emailQueueUrl = sqsClient.getQueueUrl(r -> r.queueName("order-email-notifications"))
.get().queueUrl();
String emailQueueArn = sqsClient.getQueueAttributes(
r -> r.queueUrl(emailQueueUrl).attributeNamesWithStrings("QueueArn")
).get().attributesAsStrings().get("QueueArn");
snsClient.subscribe(r -> r
.topicArn(topicArn)
.protocol("sqs")
.endpoint(emailQueueArn)
).get();
// Store the topic ARN for use in tests
System.setProperty("sns.order-events.arn", topicArn);
}
@Test
void shouldFanOutToAllSubscribedQueues() {
String topicArn = System.getProperty("sns.order-events.arn");
snsClient.publish(r -> r
.topicArn(topicArn)
.message("{\"orderId\": \"order-1\", \"event\": \"CONFIRMED\"}")
);
await()
.atMost(Duration.ofSeconds(15))
.until(() -> emailConsumer.getReceivedMessages().size() >= 1);
assertThat(emailConsumer.getReceivedMessages()).hasSize(1);
}
DynamoDB Testing
@Container
@ServiceConnection
static LocalStackContainer localstack = new LocalStackContainer(
DockerImageName.parse("localstack/localstack:3.4.0")
).withServices(LocalStackContainer.Service.DYNAMODB);
@BeforeAll
static void createDynamoDBTable(@Autowired DynamoDbClient dynamoDbClient) {
dynamoDbClient.createTable(r -> r
.tableName("orders")
.keySchema(
KeySchemaElement.builder().attributeName("orderId").keyType(KeyType.HASH).build()
)
.attributeDefinitions(
AttributeDefinition.builder()
.attributeName("orderId")
.attributeType(ScalarAttributeType.S)
.build()
)
.billingMode(BillingMode.PAY_PER_REQUEST)
);
}
Common Pitfalls
Not creating AWS resources before tests. LocalStack starts empty. S3 buckets, SQS queues, and SNS topics do not exist until you create them. Always create required resources in @BeforeAll or @BeforeEach.
Forgetting to set endpoint-override. Without endpoint override configuration, the AWS SDK tries to reach real AWS (*.amazonaws.com). This fails because LocalStack is running locally. Always configure the endpoint override to point to LocalStack.
LocalStack startup time. LocalStack takes 10–30 seconds to start, especially on first run. Do not lower the timeout below 60 seconds.
Summary
LocalStackContainer provides a local AWS API emulator for testing S3, SQS, SNS, DynamoDB, and other services. Create required resources in @BeforeAll, use the AWS SDK or Spring Cloud AWS to configure endpoint overrides, and test full AWS integration flows without real AWS credentials or costs.
The next article covers Keycloak and OAuth2 testing — integrating Spring Security JWT validation tests with a real Keycloak instance.