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

  • LocalStackContainer setup and @ServiceConnection
  • Creating S3 buckets and testing file upload/download
  • Testing SQS message producers and @SqsListener consumers
  • 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.

Next: Keycloak and OAuth2 Testing with Testcontainers