How to Test AWS Services Locally in Python Without LocalStack
There’s a ritual every Python developer on AWS goes through. You write a function that puts an object in S3, sends a message to SQS, and…
How to Test AWS Services Locally in Python Without LocalStack
There’s a ritual every Python developer on AWS goes through. You write a function that puts an object in S3, sends a message to SQS, and stores a record in DynamoDB. Then you need to test it.
And that’s where the fun stops.
You open the test file and stare at the options. moto decorators? LocalStack containers? Raw unittest.mock.patch with MagicMock? Each one works, sort of. Each one hurts, eventually.
The choice you make here determines whether your test suite is something you trust or something you tolerate.
The moto Ceiling
moto is excellent. It’s the most popular AWS mocking library in Python for good reason: it’s well-maintained, covers a wide range of services, and the decorator API is clean.
import boto3
import moto
import json
@moto.mock_s3
@moto.mock_sqs
@moto.mock_dynamodb
def test_process_order():
# S3
s3 = boto3.client("s3", region_name="us-east-1")
s3.create_bucket(Bucket="receipts")
# DynamoDB
ddb = boto3.client("dynamodb", region_name="us-east-1")
ddb.create_table(
TableName="Orders",
AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}],
KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}],
BillingMode="PAY_PER_REQUEST",
)
# SQS
sqs = boto3.client("sqs", region_name="us-east-1")
sqs.create_queue(QueueName="notifications")
# Run the actual application code
process_order("order-001", amount=49.99)
# Verify outcomes…
This works well for a single-cloud, AWS-only codebase. The test is readable. The mocks maintain state. S3 PutObject followed by GetObject returns what was put.
But you hit the ceiling in three common scenarios:
Scenario 1: Your application isn’t AWS-only. The moment you add a Stripe call, a PostgreSQL query, or a Redis cache lookup, moto can’t help. You need to combine moto with responses (or requests-mock) for HTTP, a Docker Postgres container for SQL, and fakeredis for caching. Three decorators from moto, plus two more mocking systems, plus a container. The test setup becomes a dependency management problem.
Scenario 2: Decorator stacking gets unwieldy. A test that touches S3, DynamoDB, SQS, SNS, Lambda, SecretsManager, and SSM needs seven decorators:
@moto.mock_s3
@moto.mock_dynamodb
@moto.mock_sqs
@moto.mock_sns
@moto.mock_lambda
@moto.mock_secretsmanager
@moto.mock_ssm
def test_full_workflow():
…
This works, but it’s noisy. Every new service touched by the code requires adding another decorator to every test that exercises that code path. Miss one, and you get a cryptic botocore error about missing credentials instead of a clear test failure.
Scenario 3: moto doesn’t cover the service you need. moto’s coverage is broad, but it’s not complete. ECS Fargate task networking, EKS addon management, RDS Proxy creation, CloudWatch GetMetricData with complex queries. These have gaps or behave differently than real AWS. When you hit a gap, the fallback is usually unittest.mock.patch, and now you have two mocking patterns in one test file.
None of these are criticisms of moto. It’s a great library doing exactly what it was designed to do. The limitation is architectural: moto operates at the SDK level, intercepting boto3 client methods. That means it needs a separate implementation for every AWS API operation, and it can’t help with anything that isn’t boto3.
The LocalStack Tax
LocalStack goes the other direction. Instead of mocking at the SDK level, it runs a Docker container that simulates AWS services.
# docker-compose.test.yml
services:
localstack:
image: localstack/localstack:latest
ports:
- "4566:4566"
environment:
- SERVICES=s3,sqs,dynamodb,secretsmanager,lambda,sns
The advantage is fidelity. LocalStack aims to match real AWS behavior, including error responses, request validation, and service interactions. The disadvantage is everything else.
LocalStack takes 30–120 seconds to start. On CI, that’s 30–120 seconds of billable compute before a single test runs. On a developer laptop, it’s 30–120 seconds of staring at Docker logs while the fan spins up.
The free tier is missing services. Lambda execution, IAM policy evaluation, and several other features require LocalStack Pro, which costs money per developer seat.
Version mismatches between local and CI are a recurring source of flaky tests. LocalStack updates change API behavior, error messages, and service coverage. A test that passes locally against LocalStack 3.2 can fail on CI against LocalStack 3.4 because PutItem validation changed.
And the operational burden is real. Docker needs to be running. Ports need to be available. Health checks need to pass. Volumes occasionally corrupt. CI runners need enough memory for both the test process and the container. These aren’t show-stoppers individually, but they accumulate into a constant background tax on developer productivity.
What If You Could Keep moto’s Ergonomics Without Its Boundaries?
The pattern that moto gets right is in-process interception. No containers, instant startup, state that persists within a test. The pattern it constrains is scope: only boto3, only AWS, only the operations it implements.
MockMesh takes the same in-process approach but operates one layer deeper, at the botocore transport layer. Instead of intercepting individual boto3 client methods (put_object, send_message, put_item), it intercepts botocore.endpoint.Endpoint.make_request, the single function that every AWS API call flows through regardless of which service, which operation, or which SDK abstraction you use.
import boto3
import mockmesh
mockmesh.initialize()
# Every boto3 call is now intercepted. No decorators. No per-service setup.
s3 = boto3.client("s3", region_name="us-east-1")
ddb = boto3.client("dynamodb", region_name="us-east-1")
sqs = boto3.client("sqs", region_name="us-east-1")
Two lines. Every AWS service intercepted. No decorator stacking, no per-service mock initialization, no container.
A Real AWS Workflow, End to End
Here’s a test that exercises a realistic order processing pipeline, the kind that touches half a dozen AWS services in a single request:
import json
import boto3
import mockmesh
mockmesh.initialize()
def test_order_processing_pipeline():
# ── Set up clients ──────────────────────────────────────────
s3 = boto3.client("s3", region_name="us-east-1")
ddb = boto3.client("dynamodb", region_name="us-east-1")
sqs = boto3.client("sqs", region_name="us-east-1")
sns = boto3.client("sns", region_name="us-east-1")
secrets = boto3.client("secretsmanager", region_name="us-east-1")
ssm = boto3.client("ssm", region_name="us-east-1")
# ── Seed configuration ──────────────────────────────────────
secrets.create_secret(
Name="payments/stripe-key",
SecretString=json.dumps({"api_key": "sk-test-123"}),
)
ssm.put_parameter(
Name="/config/receipt-bucket",
Value="order-receipts",
Type="String",
Overwrite=True,
)
# ── Verify config is retrievable ────────────────────────────
key = secrets.get_secret_value(SecretId="payments/stripe-key")
assert json.loads(key["SecretString"])["api_key"] == "sk-test-123"
param = ssm.get_parameter(Name="/config/receipt-bucket")
assert param["Parameter"]["Value"] == "order-receipts"
# ── Set up infrastructure ───────────────────────────────────
s3.create_bucket(Bucket="order-receipts")
ddb.create_table(
TableName="Orders",
AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}],
KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}],
BillingMode="PAY_PER_REQUEST",
)
q_url = sqs.create_queue(QueueName="fulfillment")["QueueUrl"]
topic = sns.create_topic(Name="order-events")["TopicArn"]
# ── Process the order ───────────────────────────────────────
order_id = "ORD-001"
# 1. Store order in DynamoDB
ddb.put_item(
TableName="Orders",
Item={
"id": {"S": order_id},
"amount": {"N": "49.99"},
"status": {"S": "confirmed"},
},
)
# 2. Generate and store receipt in S3
receipt = json.dumps({"order_id": order_id, "amount": 49.99}).encode()
s3.put_object(
Bucket="order-receipts",
Key=f"receipts/{order_id}.json",
Body=receipt,
ContentType="application/json",
)
# 3. Queue for fulfillment
sqs.send_message(
QueueUrl=q_url,
MessageBody=json.dumps({"order_id": order_id, "action": "ship"}),
)
# 4. Publish event
sns.publish(
TopicArn=topic,
Message=json.dumps({"event": "order.confirmed", "order_id": order_id}),
)
# ── Verify every outcome ────────────────────────────────────
# DynamoDB: order exists with correct status
item = ddb.get_item(
TableName="Orders", Key={"id": {"S": order_id}}
)["Item"]
assert item["status"]["S"] == "confirmed"
assert item["amount"]["N"] == "49.99"
# S3: receipt is readable and correct
obj = s3.get_object(Bucket="order-receipts", Key=f"receipts/{order_id}.json")
body = obj["Body"]
if isinstance(body, str):
import base64
body = base64.b64decode(body)
stored_receipt = json.loads(body)
assert stored_receipt["order_id"] == order_id
assert stored_receipt["amount"] == 49.99
# SQS: fulfillment message is in the queue
msgs = sqs.receive_message(QueueUrl=q_url)["Messages"]
fulfillment = json.loads(msgs[0]["Body"])
assert fulfillment["order_id"] == order_id
assert fulfillment["action"] == "ship"
# S3: listing confirms the receipt exists
listing = s3.list_objects_v2(Bucket="order-receipts", Prefix="receipts/")
keys = [c["Key"] for c in listing.get("Contents", [])]
assert f"receipts/{order_id}.json" in keys
Six AWS services. One test function. No decorators. No containers. Every assertion verifies an outcome, that data exists in the right place with the right content, not that a specific method was called.
If someone refactors the code to use upload_fileobj instead of put_object for the receipt, the test still passes. If someone switches from put_item to batch_write_item for the DynamoDB write, the test still passes. The test is coupled to what the code does, not how it does it.
DynamoDB: Where Stateful Mocking Changes Everything
DynamoDB is the service where the difference between unittest.mock and transport-layer interception is most stark. DynamoDB’s API is inherently stateful: items are written, read, updated, scanned, and deleted. Testing DynamoDB workflows with unittest.mock means manually wiring every GetItem response to match a previous PutItem’s arguments. One new attribute added to the item, and every mock setup in the test suite needs updating.
import boto3
import mockmesh
mockmesh.initialize()
def test_inventory_update():
ddb = boto3.client("dynamodb", region_name="us-east-1")
ddb.create_table(
TableName="Inventory",
AttributeDefinitions=[{"AttributeName": "sku", "AttributeType": "S"}],
KeySchema=[{"AttributeName": "sku", "KeyType": "HASH"}],
BillingMode="PAY_PER_REQUEST",
)
# Seed inventory
ddb.put_item(
TableName="Inventory",
Item={
"sku": {"S": "WGT-001"},
"name": {"S": "Widget"},
"stock": {"N": "100"},
"warehouse": {"S": "us-east"},
},
)
# Application code: sell 3 units
ddb.update_item(
TableName="Inventory",
Key={"sku": {"S": "WGT-001"}},
UpdateExpression="SET stock = :new_stock, #s = :status",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={
":new_stock": {"N": "97"},
":status": {"S": "in-stock"},
},
)
# Verify the update
item = ddb.get_item(
TableName="Inventory", Key={"sku": {"S": "WGT-001"}}
)["Item"]
assert item["stock"]["N"] == "97"
assert item["status"]["S"] == "in-stock"
assert item["warehouse"]["S"] == "us-east" # unchanged field preserved
# Scan confirms only one item
scan = ddb.scan(TableName="Inventory")
assert scan["Count"] == 1
PutItem writes an item. UpdateItem modifies specific attributes while preserving others. GetItem reads the current state. Scan counts total items. All in-memory, all consistent, all without a DynamoDB table existing anywhere.
SecretsManager and SSM: Config Without AWS
Almost every AWS-deployed application reads configuration from SecretsManager or SSM Parameter Store at startup. Testing this has always been awkward. Either you mock the boto3 call and hardcode the return value, or you run LocalStack and wait for it to boot.
import json
import boto3
import mockmesh
mockmesh.initialize()
def test_app_reads_config_at_startup():
sm = boto3.client("secretsmanager", region_name="us-east-1")
ssm = boto3.client("ssm", region_name="us-east-1")
# Seed the secrets and parameters the app expects
sm.create_secret(
Name="myapp/database",
SecretString=json.dumps({
"host": "db.internal",
"port": 5432,
"password": "s3cr3t",
}),
)
ssm.put_parameter(
Name="/myapp/feature-flags",
Value=json.dumps({"dark_mode": True, "new_checkout": False}),
Type="String",
Overwrite=True,
)
# Application startup reads these
db_config = json.loads(
sm.get_secret_value(SecretId="myapp/database")["SecretString"]
)
flags = json.loads(
ssm.get_parameter(Name="/myapp/feature-flags")["Parameter"]["Value"]
)
assert db_config["host"] == "db.internal"
assert db_config["port"] == 5432
assert flags["dark_mode"] is True
assert flags["new_checkout"] is False
The test creates the exact configuration the application expects, then verifies the application reads it correctly. No mocking the return value. No hardcoding JSON in a side_effect. The config is stored in MockMesh’s in-memory SecretsManager, and the application code retrieves it through the normal boto3 API.
When MockMesh Isn’t Enough
Honest trade-offs. MockMesh doesn’t replace real AWS for everything.
IAM policy evaluation doesn’t exist. If your test needs to verify that a specific IAM role lacks permission to access a specific S3 bucket, MockMesh won’t catch it. IAM is an authorization layer above the transport, and MockMesh intercepts below it.
DynamoDB conditional expressions are simplified. Basic SET, REMOVE, and attribute updates work. Complex ConditionExpression with nested AND/OR/NOT, attribute_exists checks, and comparison functions may not match AWS’s exact behavior.
Lambda Invoke returns a default payload. MockMesh doesn’t execute your Lambda function code. It returns a 200 response with a mock payload. If you need to test the actual Lambda handler logic, call the handler function directly.
API fidelity isn’t byte-for-byte. MockMesh returns the right shape for every response: the right keys, the right types, the right nesting. But edge cases like exact error message wording, request ID formats, or throttling behavior may differ from real AWS.
The right mental model: MockMesh is for testing application logic that uses AWS services. When you need to test AWS behavior itself (IAM boundaries, complex query expressions, service-specific edge cases), use real AWS or LocalStack.
The Migration Path
You don’t have to rewrite your test suite. MockMesh works alongside existing tools.
Step 1: Replace container-dependent tests first. Tests that currently need a running LocalStack or Docker container are the highest-value targets. Replace the container dependency with mockmesh.initialize() and remove the Docker setup.
Step 2: Simplify moto decorator stacks. Tests with four or more moto decorators can be simplified to a single initialize() call. The test assertions stay the same; only the setup changes.
Step 3: Keep moto for edge cases. If a specific test relies on moto’s deep implementation of a particular DynamoDB query pattern or S3 lifecycle rule, keep moto for that test. MockMesh and moto can coexist in the same test suite.
Step 4: Use fallback_mode=”error” in CI. This catches any AWS API call that MockMesh doesn’t handle, so you know immediately when coverage gaps matter.
# conftest.py
import pytest
import mockmesh
@pytest.fixture(autouse=True)
def mock_aws():
with mockmesh.engine(fallback_mode="error") as mm:
yield mm 메타데이터
- post_id
- 243d914f683c
- slug
- how-to-test-aws-services-locally-in-python-without-localstack-243d914f683c
- url
- https://medium.com/@ayush-pradhan/how-to-test-aws-services-locally-in-python-without-localstack-243d914f683c
- canonical_url
- https://medium.com/@ayush-pradhan/how-to-test-aws-services-locally-in-python-without-localstack-243d914f683c
- author_url
- https://medium.com/@ayush-pradhan
- status
- ok
- fetched_at
- 2026-06-22 18:00:48