LocalStack for AWS Testing: Run AWS Services Locally Without Mocking

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: 10

Start LocalStack for local development:

docker compose -f docker-compose.test.yml up -d localstack

Wait for it to be healthy:

curl http://localhost:4566/_localstack/health

Python + 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"] == 1

Testing 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) == 1

Testing 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-1

awslocal 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.

Read more