Skip to content

How to Test AWS Services Locally with Testcontainers and LocalStack

I had a bug that only appeared in production. My tests passed. My mocks looked correct. But when real SQS messages arrived, the system broke.

The problem? SQS visibility timeout. My mock returned messages instantly. Real SQS doesn’t work that way. After receiving a message, it becomes invisible to other consumers for a configurable period. My mock ignored this behavior completely.

This is the mock drift problem. Your mocks drift away from real API behavior over time. The AWS API contracts are complex and always evolving. Testing against real AWS is expensive and slow. But testing with mocks catches nothing.

Then I found the solution: Testcontainers + LocalStack.

What is LocalStack?

LocalStack is a local cloud emulator. It runs in Docker and provides the same APIs as AWS. It supports 70+ AWS services including S3, SQS, SNS, DynamoDB, Lambda, and more.

Think of it as a local AWS that runs on your machine. You get real AWS behavior without AWS costs.

The Mock Drift Problem

Let me show you a typical mock-based test:

MockBasedTest.java
// Mock-based test - passes but real AWS fails
when(sqsClient.receiveMessage(request))
.thenReturn(new ReceiveMessageResult()
.withMessages(message));
// Problem: Mock doesn't validate visibility timeout semantics

This test passes. But it tests nothing about how SQS actually works.

A developer on Reddit shared this insight:

“Caught a real SQS message visibility timeout bug that way that would never have shown up in a mocked test.”

At that point, Testcontainers stops being a “database testing tool” and becomes the integration layer for your entire external dependency graph.

LocalStack vs. Traditional Mocks

AspectLocalStackTraditional Mocks
API ContractReal AWS APIDeveloper’s interpretation
MaintenanceAuto-updates with new versionsManual updates required
Edge CasesCatches real behaviorsOnly tests what you mock
CostFree (local)Free
Test ReliabilityHighMedium to Low

Getting Started with Spring Boot

You need Java 17+, Spring Boot 3.x, Docker, and AWS SDK v2.

Add Dependencies

For Maven:

pom.xml
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-localstack</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.21.2</version>
<scope>test</scope>
</dependency>

For Gradle:

build.gradle
testImplementation "org.testcontainers:testcontainers-localstack:2.0.1"
testImplementation "org.testcontainers:junit-jupiter:1.21.2"

Basic LocalStack Container Setup

S3ServiceIntegrationTest.java
import org.testcontainers.containers.localstack.LocalStackContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import static org.testcontainers.containers.localstack.LocalStackContainer.Service.*;
@Testcontainers
@SpringBootTest
class S3ServiceIntegrationTest {
@Container
static LocalStackContainer localstack =
new LocalStackContainer(DockerImageName.parse("localstack/localstack:3"))
.withServices(S3, SQS, SNS, DYNAMODB);
// Container lifecycle managed automatically
}

Key points:

  • @Testcontainers enables automatic container lifecycle management
  • @Container with static means the container is shared across test methods
  • .withServices() specifies which AWS services to enable

Testing S3

First, create an S3 client that connects to LocalStack:

S3Test.java
@BeforeEach
void setUp() {
S3Client s3 = S3Client.builder()
.endpointOverride(localstack.getEndpointOverride(S3))
.credentialsProvider(
StaticCredentialsProvider.create(
AwsBasicCredentials.create(
localstack.getAccessKey(),
localstack.getSecretKey()
)
)
)
.region(Region.of(localstack.getRegion()))
.build();
}

Now write a real test:

S3UploadTest.java
@Test
void shouldUploadAndRetrieveFile() {
// Create bucket
s3.createBucket(b -> b.bucket("test-bucket"));
// Upload file
s3.putObject(p -> p
.bucket("test-bucket")
.key("test-file.txt"),
RequestBody.fromString("Hello LocalStack"));
// Verify
GetObjectResponse response = s3.getObject(g -> g
.bucket("test-bucket")
.key("test-file.txt")).response();
assertThat(response.contentLength()).isGreaterThan(0);
}

This test exercises real S3 API calls. It catches issues like:

  • Wrong bucket naming
  • Missing permissions
  • Incorrect key formats

Testing SQS - The Visibility Timeout Case

Here’s the test that would have caught my production bug:

SQSVisibilityTest.java
@Test
void shouldHandleVisibilityTimeout() throws InterruptedException {
// Create queue
String queueUrl = sqs.createQueue(q -> q
.queueName("test-queue")
.attributes(Map.of(
QueueAttributeName.VISIBILITY_TIMEOUT, "5"
))).queueUrl();
// Send message
sqs.sendMessage(m -> m
.queueUrl(queueUrl)
.messageBody("test message"));
// Receive and verify visibility timeout behavior
ReceiveMessageResponse response1 = sqs.receiveMessage(r -> r
.queueUrl(queueUrl)
.visibilityTimeout(5)
.waitTimeSeconds(2));
assertThat(response1.messages()).hasSize(1);
// Immediately receive again - should be empty due to visibility timeout
ReceiveMessageResponse response2 = sqs.receiveMessage(r -> r
.queueUrl(queueUrl)
.waitTimeSeconds(1));
assertThat(response2.messages()).isEmpty(); // This would PASS with mock!
// Wait for visibility timeout to expire
Thread.sleep(6000);
// Now message should be visible again
ReceiveMessageResponse response3 = sqs.receiveMessage(r -> r
.queueUrl(queueUrl)
.waitTimeSeconds(2));
assertThat(response3.messages()).hasSize(1);
}

Why mocks fail here:

  • Mocks typically return the message regardless of visibility timeout
  • Real SQS semantics are complex (visibility timeout, receive count, etc.)
  • Only a real API reveals this behavior

Testing SNS + SQS Integration

The fanout pattern is common in event-driven systems. One message goes to multiple queues:

SNSFanoutTest.java
@Test
void shouldFanoutMessagesToMultipleQueues() {
// Create topic
String topicArn = sns.createTopic(t -> t
.name("orders-topic")).topicArn();
// Create two queues
String queue1Url = sqs.createQueue(q -> q
.queueName("order-processor-1")).queueUrl();
String queue2Url = sqs.createQueue(q -> q
.queueName("order-processor-2")).queueUrl();
// Subscribe both queues to the topic
sns.subscribe(s -> s
.topicArn(topicArn)
.protocol("sqs")
.endpoint(getQueueArn(queue1Url)));
sns.subscribe(s -> s
.topicArn(topicArn)
.protocol("sqs")
.endpoint(getQueueArn(queue2Url)));
// Publish message
sns.publish(p -> p
.topicArn(topicArn)
.message("{\"orderId\": \"123\"}"));
// Verify both queues received the message
assertThat(sqs.receiveMessage(r -> r.queueUrl(queue1Url)).messages())
.hasSize(1);
assertThat(sqs.receiveMessage(r -> r.queueUrl(queue2Url)).messages())
.hasSize(1);
}

This test catches subscription configuration errors and message routing problems.

Testing DynamoDB

DynamoDB has complex query patterns with Global Secondary Indexes:

DynamoDBTest.java
@Test
void shouldQueryItemsByGsi() {
// Create table with GSI
dynamodb.createTable(t -> t
.tableName("users")
.attributeDefinitions(
AttributeDefinition.builder()
.attributeName("userId").attributeType(ScalarAttributeType.S).build(),
AttributeDefinition.builder()
.attributeName("email").attributeType(ScalarAttributeType.S).build()
)
.keySchema(KeySchemaElement.builder()
.attributeName("userId").keyType(KeyType.HASH).build())
.globalSecondaryIndexes(GlobalSecondaryIndex.builder()
.indexName("email-index")
.keySchema(KeySchemaElement.builder()
.attributeName("email").keyType(KeyType.HASH).build())
.projection(Projection.builder()
.projectionType(ProjectionType.ALL).build())
.provisionedThroughput(ProvisionedThroughput.builder()
.readCapacityUnits(5L).writeCapacityUnits(5L).build())
.build())
.provisionedThroughput(ProvisionedThroughput.builder()
.readCapacityUnits(5L).writeCapacityUnits(5L).build())
.build());
// Insert test data
dynamodb.putItem(p -> p
.tableName("users")
.item(Map.of(
"userId", AttributeValue.fromS("user-1"),
"email", AttributeValue.fromS("[email protected]"),
"name", AttributeValue.fromS("Test User")
)));
// Query by GSI
QueryResponse response = dynamodb.query(q -> q
.tableName("users")
.indexName("email-index")
.keyConditionExpression("email = :email")
.expressionAttributeValues(Map.of(
":email", AttributeValue.fromS("[email protected]")
)));
assertThat(response.count()).isEqualTo(1);
}

This test catches GSI configuration errors that would only surface in production.

Spring Boot Integration Best Practices

Dynamic Property Source Pattern

Spring Boot can dynamically configure AWS properties from the container:

OrderServiceIntegrationTest.java
@Testcontainers
@SpringBootTest
class OrderServiceIntegrationTest {
@Container
static LocalStackContainer localstack =
new LocalStackContainer(DockerImageName.parse("localstack/localstack:3"))
.withServices(S3, SQS, SNS, DYNAMODB);
@DynamicPropertySource
static void configureAwsProperties(DynamicPropertyRegistry registry) {
registry.add("aws.endpoint", () -> localstack.getEndpointOverride(S3).toString());
registry.add("aws.access-key", localstack::getAccessKey);
registry.add("aws.secret-key", localstack::getSecretKey);
registry.add("aws.region", localstack::getRegion);
}
}

Your application properties:

application-test.yml
aws:
endpoint: ${AWS_ENDPOINT:http://localhost:4566}
access-key: ${AWS_ACCESS_KEY:test}
secret-key: ${AWS_SECRET_KEY:test}
region: ${AWS_REGION:us-east-1}

Reusable Test Configuration

Create a test configuration class:

LocalStackTestConfig.java
@TestConfiguration
public class LocalStackTestConfig {
@Bean
@Primary
public S3Client s3Client(LocalStackContainer localstack) {
return S3Client.builder()
.endpointOverride(localstack.getEndpointOverride(S3))
.credentialsProvider(
StaticCredentialsProvider.create(
AwsBasicCredentials.create(
localstack.getAccessKey(),
localstack.getSecretKey()
)
)
)
.region(Region.of(localstack.getRegion()))
.build();
}
// Similar beans for SQS, SNS, DynamoDB...
}

Advanced Patterns

Single-Container Multi-Service Setup

One container can run multiple services:

MultiServiceTest.java
@Container
static LocalStackContainer localstack =
new LocalStackContainer(DockerImageName.parse("localstack/localstack:3"))
.withServices(S3, SQS, SNS, DYNAMODB)
.withReuse(true); // Reuse across test classes

Note: .withReuse(true) keeps the container running between test runs. It’s faster but requires manual cleanup.

Testcontainers Network for Multi-Container Tests

Sometimes your application container needs to access LocalStack:

MultiContainerTest.java
@Testcontainers
class MultiContainerTest {
static Network network = Network.newNetwork();
@Container
static LocalStackContainer localstack =
new LocalStackContainer(DockerImageName.parse("localstack/localstack:3"))
.withNetwork(network)
.withNetworkAliases("localstack");
@Container
static GenericContainer<?> app =
new GenericContainer<>(DockerImageName.parse("my-app:latest"))
.withNetwork(network)
.withEnv("AWS_ENDPOINT", "http://localstack:4566")
.dependsOn(localstack);
}

CI/CD Integration

Testcontainers works with GitHub Actions out of the box:

.github/workflows/integration-tests.yml
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Run Integration Tests
run: ./mvnw verify -Pintegration-test
# Testcontainers automatically starts LocalStack

No special configuration needed if Docker is available.

Common Pitfalls and Solutions

”Service not running” errors

LocalStack container starts but specific service isn’t ready.

Solution: Use a healthcheck:

HealthcheckTest.java
@Container
static LocalStackContainer localstack =
new LocalStackContainer(DockerImageName.parse("localstack/localstack:3"))
.withServices(S3)
.waitingFor(
Wait.forHttp("/_localstack/health")
.forPort(4566)
.forStatusCode(200)
);

Port conflicts on CI

Multiple test jobs running in parallel cause port conflicts.

Solution: Don’t hardcode ports. Testcontainers assigns them dynamically:

PortConfigTest.java
// BAD
String endpoint = "http://localhost:4566";
// GOOD
String endpoint = localstack.getEndpointOverride(S3).toString();

Slow test execution

Tests take too long due to container startup.

Solutions:

  1. Use @Container static to share container across test methods
  2. Enable container reuse: .withReuse(true) plus testcontainers.reuse.enable=true in ~/.testcontainers.properties
  3. Use smaller LocalStack image: localstack/localstack:3.0.2-lite

Real-World Benefits

Bug Discovery

Real AWS behaviors that mocks miss:

  • SQS message visibility timeout (the one that bit me)
  • S3 eventual consistency behaviors
  • DynamoDB conditional write failures
  • SQS message deduplication quirks
  • SNS message attribute preservation

Development Velocity

  • No AWS account needed for initial development
  • No network latency to real AWS
  • Faster test feedback loops
  • Offline development capability

Cost Savings

  • Zero AWS costs during development
  • No accidental resource cleanup failures
  • No AWS Free Tier exhaustion

Team Collaboration

  • Consistent test environments across team
  • No shared AWS account conflicts
  • Reproducible test data

When NOT to Use LocalStack

LocalStack has limitations:

  • Not all AWS services are fully supported
  • Some edge cases differ from real AWS
  • Performance characteristics differ

Use real AWS testing for:

  • Production deployment validation
  • Load testing with realistic latency
  • Testing AWS-specific features not in LocalStack
  • IAM policy validation

Hybrid Approach

HybridTest.java
@Test
@Tag("localstack")
void shouldHandleMessageVisibility_LocalStack() {
// Fast, runs on every commit
}
@Test
@Tag("aws-integration")
@Disabled("Run manually before release")
void shouldHandleMessageVisibility_RealAWS() {
// Slow, runs before production deployment
}

Key Takeaways

  1. Testcontainers + LocalStack gives you real AWS API contracts in isolated containers
  2. You catch bugs that mocks miss (visibility timeout, eventual consistency, etc.)
  3. Spring Boot integration is seamless with @DynamicPropertySource
  4. It’s not just for databases. Think of it as your integration layer for all external dependencies

Start with one service. S3 is easiest. Then expand to SQS, SNS, DynamoDB. Your future self will thank you when you catch that visibility timeout bug before it hits production.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments