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:
// Mock-based test - passes but real AWS failswhen(sqsClient.receiveMessage(request)) .thenReturn(new ReceiveMessageResult() .withMessages(message));
// Problem: Mock doesn't validate visibility timeout semanticsThis 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
| Aspect | LocalStack | Traditional Mocks |
|---|---|---|
| API Contract | Real AWS API | Developer’s interpretation |
| Maintenance | Auto-updates with new versions | Manual updates required |
| Edge Cases | Catches real behaviors | Only tests what you mock |
| Cost | Free (local) | Free |
| Test Reliability | High | Medium to Low |
Getting Started with Spring Boot
You need Java 17+, Spring Boot 3.x, Docker, and AWS SDK v2.
Add Dependencies
For Maven:
<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:
testImplementation "org.testcontainers:testcontainers-localstack:2.0.1"testImplementation "org.testcontainers:junit-jupiter:1.21.2"Basic LocalStack Container Setup
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@SpringBootTestclass S3ServiceIntegrationTest {
@Container static LocalStackContainer localstack = new LocalStackContainer(DockerImageName.parse("localstack/localstack:3")) .withServices(S3, SQS, SNS, DYNAMODB);
// Container lifecycle managed automatically}Key points:
@Testcontainersenables automatic container lifecycle management@Containerwithstaticmeans 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:
@BeforeEachvoid 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:
@Testvoid 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:
@Testvoid 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:
@Testvoid 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:
@Testvoid 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"), "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( )));
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:
@Testcontainers@SpringBootTestclass 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:
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:
@TestConfigurationpublic 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:
@Containerstatic LocalStackContainer localstack = new LocalStackContainer(DockerImageName.parse("localstack/localstack:3")) .withServices(S3, SQS, SNS, DYNAMODB) .withReuse(true); // Reuse across test classesNote: .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:
@Testcontainersclass 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:
name: Integration Testson: [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 LocalStackNo 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:
@Containerstatic 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:
// BADString endpoint = "http://localhost:4566";
// GOODString endpoint = localstack.getEndpointOverride(S3).toString();Slow test execution
Tests take too long due to container startup.
Solutions:
- Use
@Container staticto share container across test methods - Enable container reuse:
.withReuse(true)plustestcontainers.reuse.enable=truein~/.testcontainers.properties - 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
@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
- Testcontainers + LocalStack gives you real AWS API contracts in isolated containers
- You catch bugs that mocks miss (visibility timeout, eventual consistency, etc.)
- Spring Boot integration is seamless with
@DynamicPropertySource - 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