Testing Java microservices with Testcontainers and LocalStack
Java microservices often meet AWS through queues, object storage, event streams and managed databases. Those boundaries are where release confidence is hardest to earn: a unit test can prove that code compiles, while a deployment can still fail when IAM policies, bucket settings or message contracts drift.
Testcontainers and LocalStack close that gap by creating disposable, AWS-shaped infrastructure beside a Spring Boot service. The result is an integration test that exercises real SDK calls and container wiring without sharing production credentials or paying for every experiment.
Why local AWS emulation belongs in the test suite
Testcontainers does not emulate AWS by itself. It manages disposable containers through Docker, while LocalStack supplies an AWS-compatible endpoint inside one of those containers. A test can create a bucket, publish an SQS message or write to DynamoDB through the same AWS SDK used in production, with cleanup handled as the test environment exits.
This catches errors that mocks often hide: endpoint formatting, region settings, serialisation, queue visibility, retry behaviour and the awkward details of credentials or headers. The application still owns its business logic, but it must negotiate a genuine HTTP boundary instead of returning a canned Java object.
The approach does not make emulation equivalent to AWS. IAM policy evaluation, service quotas, rare failure modes and some managed-service features can differ. LocalStack is strongest as a repeatable integration layer, paired with targeted checks against a real AWS sandbox for the behaviours that matter most.
Designing a reliable container test environment
Treat the container as a suite fixture. Start one LocalStack instance for a test class or a shared integration-test slice, then let individual tests use unique buckets, queues and keys. Starting a fresh stack for every method multiplies boot time without adding useful isolation; reusing one mutable stack across unrelated tests creates order-dependent failures.
Pin the LocalStack image to an approved tag or digest instead of latest. Request only the services required by the scenario, and use health checks to wait for readiness rather than guessing with a fixed delay. Testcontainers can reuse a container on a developer machine, but reuse should be opt-in and normally disabled on continuous integration workers so one run cannot inherit another run’s state.
Design tests around bounded resources. Give every object a run identifier, delete test data after assertions where practical, and set short SDK timeouts. If several tests execute in parallel, derive account, queue and table names independently so they cannot collide. These small choices make failures reproducible on a laptop in Brisbane and on a Linux runner in a Sydney-based delivery pipeline.
Wiring Spring Boot to emulated AWS services
Keep emulator-specific values outside application code. Inject them through Spring’s dynamic property registry, and give the application ordinary AWS client beans. A compact JUnit setup can look like this, using a pinned image selected by the team:
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderWorkflowIntegrationTest {
@Container
static final LocalStackContainer AWS =
new LocalStackContainer(DockerImageName.parse("localstack/localstack:4.0.0"))
.withServices(S3, SQS);
@DynamicPropertySource
static void awsProperties(DynamicPropertyRegistry registry) {
registry.add("spring.cloud.aws.region.static", () -> "ap-southeast-2");
registry.add("spring.cloud.aws.credentials.access-key", AWS::getAccessKey);
registry.add("spring.cloud.aws.credentials.secret-key", AWS::getSecretKey);
registry.add("spring.cloud.aws.s3.endpoint", () -> AWS.getEndpointOverride(S3));
registry.add("spring.cloud.aws.s3.path-style-access", () -> "true");
registry.add("spring.cloud.aws.sqs.endpoint", () -> AWS.getEndpointOverride(SQS));
}
}
Here, S3 and SQS refer to LocalStack’s enabled-service constants. Spring Cloud AWS property names can vary by release, so the important contract is the injected endpoint, region and credentials. The production profile must continue to use its normal credential chain rather than the emulator’s test values.
Service-specific configuration deserves attention. S3 commonly needs path-style access, while SQS and SNS can use different endpoint paths or queue URLs. Assert on the URL that the client receives, because a LocalStack hostname embedded in a queue response can break code that assumes every AWS resource has the same host.
Comparing mocks, emulation and real AWS
The choices have different strengths and should be combined according to the risk being tested:
| Approach | What it proves | Main trade-off |
|---|---|---|
| Plain mocks | Controller and business-logic behaviour | Misses HTTP, SDK and configuration errors |
| Testcontainers with LocalStack | Container wiring and realistic AWS API exchanges | Adds boot time and emulator fidelity limits |
| Ephemeral AWS sandbox | Behaviour of a specific managed service | Costs money and needs secure cleanup |
| Shared AWS test account | Broad service compatibility under AWS controls | Resource leakage and cross-team interference are harder to control |
Mocks remain useful for thousands of fast business-rule checks. Container-backed emulation earns its place in the critical path that publishes events, stores objects or checks a service response. Real AWS checks are most valuable for newly released SDK behaviour, advanced IAM conditions and service features whose local implementation differs from the cloud.
Keeping the feedback loop deterministic
AWS workflows often involve time. Queue visibility, eventual consistency and retry backoff can turn an otherwise correct assertion into a flaky test. Replace fixed sleeps with polling at a bounded interval, wait for a business condition rather than a guessed duration, and give every attempt a deadline. Use a fixed clock when code signs requests or creates expiry-sensitive records.
Retries need explicit treatment. A production client may retry a throttled request three times, but a test that silently repeats can hide a broken endpoint. Keep client retry counts visible in logs, use unique resources on each attempt, and record which attempt satisfied the assertion. The same discipline applies to cleanup: a failed test should leave diagnosable state without poisoning the next run.
Container and JVM startup also contribute to waiting time. Measure them separately rather than blaming a slow suite on LocalStack. For JVM warm-up measurements, measuring Spring Boot startup separates application readiness from time spent pulling and initialising containers. Cache the image on controlled runners, reuse the fixture across related tests and archive startup timings with the build results.
Adapting the suite to Australian delivery teams
Australian engineering teams are spread across Sydney, Melbourne and Brisbane, with smaller cohorts in Perth and Adelaide, while most consumer demand and cloud capacity remain concentrated on the east coast. AWS provides Sydney through ap-southeast-2 and Melbourne through ap-southeast-4. Local tests should use the same region identifier as the target environment, even though LocalStack does not run the workload in either physical region.
The local enterprise market is strongly influenced by banking, insurance, health services, retail and government. Buyers in those sectors often require auditable access controls, data-residency evidence and repeatable change controls. A local emulator supports repeatable engineering evidence, but it cannot replace an organisation’s security assessment, and test data should never contain real Australian customer records.
Communication across Australian teams is usually direct and informal. Phrases such as “have a go” and “no worries” can soften a failed pipeline, but they should not blur ownership: the engineer still needs a clear error, container logs and a rollback decision. A shared runbook should specify image versions, resource limits and escalation paths so a failure has the same meaning in Adelaide as it does in Melbourne.
Building a practical release gate
Use several levels of evidence without making every developer wait for cloud infrastructure. Run fast unit tests on each change, run the Testcontainers and LocalStack scenario for the affected service before merge, and schedule selected checks against an ephemeral AWS sandbox for service fidelity. Continuous delivery then combines fast local feedback with a slower check of the AWS behaviours that local emulation cannot model.
Measure the gate as a product: duration, startup cost, failure categories, retry counts and flaky-test rate. Preserve the container image digest, generated configuration and service logs with the build. A dedicated runner with suitable CPU and memory often provides more predictable results than several jobs sharing a general-purpose pipeline, especially when the suite starts multiple JVMs alongside LocalStack.
The practical takeaway is to standardise one pinned, LocalStack-backed scenario around the riskiest AWS workflow, require it in CI, and reserve real AWS checks for behaviours the emulator cannot reproduce. That combination gives releases a repeatable signal, controls cloud cost and keeps production access safely out of everyday testing.