LocalStack for AWS Testing: Run AWS Services Locally Without Mocking
LocalStack runs a local mock of AWS services in Docker. Instead of mocking the AWS SDK in your tests, point the SDK at LocalStack's endpoint and test against a real (local) S3, DynamoDB, SQS, or Lambda. This catches integration bugs that SDK mocks miss: serialization issues, IAM permission errors in resource policies, and SDK version incompatibilities.
Key Takeaways
LocalStack is a Docker container, not a test library. You run it with Docker Compose alongside your tests and point your AWS SDK at localhost:4566 instead of real AWS endpoints.
No AWS credentials needed. LocalStack accepts any AWS credentials—use "test"/"test" for access key and secret. Nothing is sent to AWS.
LocalStack Community covers the most common services. S3, DynamoDB, SQS, SNS, Lambda, API Gateway, IAM, CloudFormation, and more. Pro covers additional services like RDS and ElasticSearch.
Use a conftest.py fixture to set up and tear down resources. Create buckets, tables, and queues in a pytest fixture, run your tests, then let LocalStack reset between test sessions.
The awslocal CLI wraps the AWS CLI for LocalStack. Use awslocal s3 ls instead of aws --endpoint-url http://localhost:4566 s3 ls.
Why LocalStack Instead of Mocking
When you mock the AWS SDK in unit tests, you're testing that your code calls the mock correctly—not that the code works with real AWS services. SDK mocks miss:
- Serialization bugs: DynamoDB has specific type handling (Decimals instead of floats, typed sets). Mocks accept anything.
- Permission errors: AWS IAM policies on S3 buckets, SQS queues, and DynamoDB tables cause errors that mocks never raise.
- SDK version behavior: Different versions of boto3 or the AWS JavaScript SDK handle edge cases differently.
- Transaction behavior: DynamoDB transactions and S3 multipart uploads have complex state machines that mocks simplify.
LocalStack lets you keep these fast, isolated tests without AWS costs or account dependencies.
Setup with Docker Compose
# docker-compose.test.yml
version: "3.8"
services:
localstack:
image: localstack/localstack:3.0
ports:
- "4566:4566" # Main LocalStack port
- "4510-4559:4510-4559" # External service ports
environment:
- SERVICES=s3,dynamodb,sqs,sns,lambda,iam
- DEBUG=0
- LAMBDA_EXECUTOR=local
- AWS_DEFAULT_REGION=us-east-1
- PERSISTENCE=0 # Don't persist state between restarts
volumes:
- /var/run/docker.sock:/var/run/docker.sock
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"]
interval: 10s
timeout: 5s
retries: 10Start LocalStack for local development:
docker compose -f docker-compose.test.yml up -d localstackWait for it to be healthy:
curl http://localhost:4566/_localstack/healthPython + pytest Configuration
# tests/conftest.py
import os
import boto3
import pytest
# Point AWS SDK at LocalStack
os.environ.setdefault("AWS_ACCESS_KEY_ID", "test")
os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "test")
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
LOCALSTACK_ENDPOINT = "http://localhost:4566"
@pytest.fixture(scope="session")
def aws_clients():
"""Return a dict of configured boto3 clients pointing at LocalStack."""
kwargs = {
"endpoint_url": LOCALSTACK_ENDPOINT,
"region_name": "us-east-1",
"aws_access_key_id": "test",
"aws_secret_access_key": "test",
}
return {
"s3": boto3.client("s3", **kwargs),
"dynamodb": boto3.resource("dynamodb", **kwargs),
"sqs": boto3.client("sqs", **kwargs),
"sns": boto3.client("sns", **kwargs),
"lambda": boto3.client("lambda", **kwargs),
}
@pytest.fixture
def s3_bucket(aws_clients):
"""Create a test S3 bucket, yield the bucket name, delete on cleanup."""
bucket_name = "test-bucket-localstack"
aws_clients["s3"].create_bucket(Bucket=bucket_name)
yield bucket_name
# Cleanup: delete all objects and the bucket
response = aws_clients["s3"].list_objects_v2(Bucket=bucket_name)
for obj in response.get("Contents", []):
aws_clients["s3"].delete_object(Bucket=bucket_name, Key=obj["Key"])
aws_clients["s3"].delete_bucket(Bucket=bucket_name)
@pytest.fixture
def orders_table(aws_clients):
"""Create a test DynamoDB table."""
table = aws_clients["dynamodb"].create_table(
TableName="orders-test",
KeySchema=[
{"AttributeName": "orderId", "KeyType": "HASH"},
],
AttributeDefinitions=[
{"AttributeName": "orderId", "AttributeType": "S"},
],
BillingMode="PAY_PER_REQUEST",
)
table.meta.client.get_waiter("table_exists").wait(TableName="orders-test")
yield table
table.delete()
@pytest.fixture
def sqs_queue(aws_clients):
"""Create a test SQS queue."""
response = aws_clients["sqs"].create_queue(QueueName="test-orders-queue")
queue_url = response["QueueUrl"]
yield queue_url
aws_clients["sqs"].delete_queue(QueueUrl=queue_url)Testing S3 Operations
# tests/test_s3_operations.py
import pytest
import boto3
import json
from myapp.s3_processor import upload_report, download_report, process_s3_event
class TestS3Operations:
def test_upload_and_download_report(self, aws_clients, s3_bucket):
report_data = {"summary": "Test report", "count": 42}
upload_report(
bucket=s3_bucket,
key="reports/2025/report.json",
data=report_data,
endpoint_url="http://localhost:4566"
)
retrieved = download_report(
bucket=s3_bucket,
key="reports/2025/report.json",
endpoint_url="http://localhost:4566"
)
assert retrieved == report_data
def test_upload_creates_correct_content_type(self, aws_clients, s3_bucket):
upload_report(
bucket=s3_bucket,
key="reports/test.json",
data={"key": "value"},
endpoint_url="http://localhost:4566"
)
head = aws_clients["s3"].head_object(Bucket=s3_bucket, Key="reports/test.json")
assert head["ContentType"] == "application/json"
def test_process_s3_event_reads_uploaded_file(self, aws_clients, s3_bucket):
# Upload a file first
aws_clients["s3"].put_object(
Bucket=s3_bucket,
Key="input/orders.json",
Body=json.dumps([{"orderId": "1", "amount": 100}])
)
# Simulate an S3 trigger event
s3_event = {
"Records": [{
"s3": {
"bucket": {"name": s3_bucket},
"object": {"key": "input/orders.json"},
}
}]
}
result = process_s3_event(s3_event, endpoint_url="http://localhost:4566")
assert result["processed_count"] == 1Testing DynamoDB Operations
# tests/test_dynamodb_operations.py
from decimal import Decimal
from myapp.order_repository import save_order, find_order, update_order_status
class TestOrderRepository:
def test_save_and_retrieve_order(self, orders_table):
order = {
"orderId": "ord-123",
"customerId": "cust-456",
"total": Decimal("99.99"),
"status": "pending",
"items": [{"productId": "p1", "quantity": 2}],
}
save_order(order, table=orders_table)
retrieved = find_order("ord-123", table=orders_table)
assert retrieved["orderId"] == "ord-123"
assert retrieved["total"] == Decimal("99.99")
assert retrieved["items"][0]["productId"] == "p1"
def test_update_order_status(self, orders_table):
order = {"orderId": "ord-456", "status": "pending", "total": Decimal("50.00")}
save_order(order, table=orders_table)
update_order_status("ord-456", "shipped", table=orders_table)
retrieved = find_order("ord-456", table=orders_table)
assert retrieved["status"] == "shipped"
def test_find_nonexistent_order_returns_none(self, orders_table):
result = find_order("nonexistent-id", table=orders_table)
assert result is None
def test_conditional_update_prevents_status_regression(self, orders_table):
order = {"orderId": "ord-789", "status": "shipped", "total": Decimal("75.00")}
save_order(order, table=orders_table)
# Trying to set a shipped order back to pending should fail
with pytest.raises(Exception):
update_order_status("ord-789", "pending", table=orders_table, conditional=True)Testing SQS Message Processing
# tests/test_sqs_processor.py
import json
from myapp.sqs_processor import publish_order_event, process_order_queue
class TestSQSProcessor:
def test_publish_and_consume_order_event(self, aws_clients, sqs_queue):
order = {"orderId": "ord-123", "status": "created", "total": 100.0}
publish_order_event(
order=order,
queue_url=sqs_queue,
endpoint_url="http://localhost:4566"
)
# Receive and process the message
processed = process_order_queue(
queue_url=sqs_queue,
endpoint_url="http://localhost:4566"
)
assert len(processed) == 1
assert processed[0]["orderId"] == "ord-123"
def test_malformed_message_is_moved_to_dlq(self, aws_clients, sqs_queue):
# Send a malformed message directly
aws_clients["sqs"].send_message(
QueueUrl=sqs_queue,
MessageBody="not valid json {"
)
processed, failed = process_order_queue(
queue_url=sqs_queue,
endpoint_url="http://localhost:4566",
return_failures=True
)
assert len(processed) == 0
assert len(failed) == 1Testing Lambda Functions via LocalStack
LocalStack Community supports local Lambda execution:
# tests/test_lambda_invocation.py
import json
import zipfile
import io
def create_lambda_zip(handler_code: str) -> bytes:
"""Package handler code as a zip for deployment."""
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w") as zip_file:
zip_file.writestr("handler.py", handler_code)
return zip_buffer.getvalue()
class TestLambdaInvocation:
def setup_method(self, aws_clients):
handler_code = """
def handler(event, context):
order_id = event.get("orderId")
return {"statusCode": 200, "body": f"Processed order {order_id}"}
"""
aws_clients["lambda"].create_function(
FunctionName="process-order",
Runtime="python3.11",
Role="arn:aws:iam::000000000000:role/test-role",
Handler="handler.handler",
Code={"ZipFile": create_lambda_zip(handler_code)},
)
def test_lambda_processes_order_event(self, aws_clients):
response = aws_clients["lambda"].invoke(
FunctionName="process-order",
Payload=json.dumps({"orderId": "ord-123"}),
)
result = json.loads(response["Payload"].read())
assert result["statusCode"] == 200
assert "ord-123" in result["body"]Node.js + Jest Integration
For Node.js, configure the AWS SDK to point at LocalStack:
// tests/setup.js
const { GenericContainer } = require('testcontainers');
let localstackContainer;
beforeAll(async () => {
localstackContainer = await new GenericContainer('localstack/localstack:3.0')
.withExposedPorts(4566)
.withEnvironment({ SERVICES: 's3,dynamodb,sqs' })
.start();
process.env.AWS_ENDPOINT_URL = `http://localhost:${localstackContainer.getMappedPort(4566)}`;
process.env.AWS_ACCESS_KEY_ID = 'test';
process.env.AWS_SECRET_ACCESS_KEY = 'test';
process.env.AWS_DEFAULT_REGION = 'us-east-1';
}, 60000);
afterAll(async () => {
await localstackContainer.stop();
});CI Configuration
# .github/workflows/localstack-tests.yml
name: LocalStack Integration Tests
on: [push, pull_request]
jobs:
integration-tests:
runs-on: ubuntu-latest
services:
localstack:
image: localstack/localstack:3.0
ports:
- 4566:4566
env:
SERVICES: s3,dynamodb,sqs,sns,lambda
LAMBDA_EXECUTOR: local
options: >-
--health-cmd "curl -f http://localhost:4566/_localstack/health"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -r requirements-test.txt
- name: Run integration tests
run: pytest tests/integration/ -v
env:
AWS_ENDPOINT_URL: http://localhost:4566
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1awslocal CLI
Install awslocal to avoid typing --endpoint-url every time:
pip install awscli-local# Create a bucket
awslocal s3 mb s3://my-test-bucket
<span class="hljs-comment"># List buckets
awslocal s3 <span class="hljs-built_in">ls
<span class="hljs-comment"># Put an item in DynamoDB
awslocal dynamodb put-item \
--table-name orders \
--item <span class="hljs-string">'{"orderId": {"S": "123"}, "status": {"S": "pending"}}'
<span class="hljs-comment"># Send an SQS message
awslocal sqs send-message \
--queue-url http://localhost:4566/000000000000/my-queue \
--message-body <span class="hljs-string">'{"orderId": "123"}'Summary
LocalStack fills the gap between unit tests (which mock the AWS SDK) and real AWS integration tests (which cost money and require credentials). Use it for:
- Testing code that uses S3, DynamoDB, SQS, and other AWS services
- Verifying IAM policies and resource-based permissions
- Testing infrastructure-as-code (CloudFormation, CDK) before deploying
- Running integration tests in CI without AWS costs
Combine LocalStack integration tests with SDK-mocked unit tests: unit tests for fast feedback, LocalStack tests for integration confidence, and real AWS tests sparingly for final validation before release.