AWS SAM Local Testing: Unit, Integration, and End-to-End
AWS SAM (Serverless Application Model) gives you infrastructure-as-code for serverless applications, but testing SAM applications before deployment has historically been painful. The sam local toolchain has matured significantly — this guide covers how to use it effectively for unit tests, local integration tests, and pre-deploy validation.
Why SAM Testing Matters
Serverless functions look simple. A handler function takes an event, does something, returns a response. But the runtime context — environment variables injected by SAM, IAM permissions, event shapes from API Gateway vs SQS vs EventBridge — makes "works on my machine" surprisingly unreliable.
SAM local lets you:
- Invoke Lambda functions locally with the actual Lambda runtime container
- Run a local API Gateway that mirrors your production configuration
- Test event-driven pipelines without deploying to AWS
- Validate IAM permissions and environment variables from
template.yaml
Project Structure
A well-structured SAM project separates handler code from business logic:
my-service/
├── template.yaml
├── functions/
│ ├── create-order/
│ │ ├── app.py
│ │ ├── handler.py # thin Lambda handler
│ │ └── requirements.txt
│ └── process-payment/
│ ├── app.py
│ ├── handler.py
│ └── requirements.txt
└── tests/
├── unit/
├── integration/
└── events/
├── api-gateway-post.json
├── sqs-message.json
└── eventbridge-event.jsonKeep handler.py thin — just parse the event and call your application code:
# handler.py
import json
from app import create_order
def lambda_handler(event, context):
try:
body = json.loads(event.get("body", "{}"))
order = create_order(body)
return {
"statusCode": 201,
"body": json.dumps(order),
"headers": {"Content-Type": "application/json"}
}
except ValueError as e:
return {
"statusCode": 400,
"body": json.dumps({"error": str(e)})
}# app.py — pure business logic, no Lambda imports
def create_order(data: dict) -> dict:
if not data.get("product_id"):
raise ValueError("product_id is required")
if not data.get("quantity") or data["quantity"] < 1:
raise ValueError("quantity must be at least 1")
return {
"order_id": generate_order_id(),
"product_id": data["product_id"],
"quantity": data["quantity"],
"status": "pending"
}Unit Tests
Test app.py without any Lambda or AWS involvement:
# tests/unit/test_create_order.py
import pytest
from functions.create_order.app import create_order
def test_create_order_success():
order = create_order({"product_id": "PROD-001", "quantity": 3})
assert order["product_id"] == "PROD-001"
assert order["quantity"] == 3
assert order["status"] == "pending"
assert "order_id" in order
def test_create_order_missing_product_id():
with pytest.raises(ValueError, match="product_id is required"):
create_order({"quantity": 1})
def test_create_order_invalid_quantity():
with pytest.raises(ValueError, match="quantity must be at least 1"):
create_order({"product_id": "PROD-001", "quantity": 0})
def test_create_order_negative_quantity():
with pytest.raises(ValueError):
create_order({"product_id": "PROD-001", "quantity": -5})Run them without SAM:
pip install -r functions/create-order/requirements.txt
pytest tests/unit/ -vEvent Test Files
Create realistic event payloads from actual AWS events. The easiest way is to grab real events from CloudWatch Logs and save them:
// tests/events/api-gateway-post.json
{
"version": "2.0",
"routeKey": "POST /orders",
"rawPath": "/orders",
"rawQueryString": "",
"headers": {
"content-type": "application/json",
"x-forwarded-for": "1.2.3.4"
},
"requestContext": {
"accountId": "123456789",
"apiId": "abcdef",
"http": {
"method": "POST",
"path": "/orders",
"protocol": "HTTP/1.1"
},
"requestId": "test-request-id",
"routeKey": "POST /orders",
"stage": "$default"
},
"body": "{\"product_id\": \"PROD-001\", \"quantity\": 2}",
"isBase64Encoded": false
}// tests/events/sqs-message.json
{
"Records": [
{
"messageId": "059f36b4-87a3-44ab-83d2-661975830a7d",
"receiptHandle": "AQEBwJnKyrHigUMZj6reyNurzo==",
"body": "{\"order_id\": \"ORD-123\", \"amount\": 99.99}",
"attributes": {
"ApproximateReceiveCount": "1",
"SentTimestamp": "1545082650636"
},
"messageAttributes": {},
"md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b0",
"eventSource": "aws:sqs",
"eventSourceARN": "arn:aws:sqs:us-east-1:123456789:MyQueue",
"awsRegion": "us-east-1"
}
]
}SAM Local Invoke
Test individual functions locally using the Lambda runtime container:
# Invoke with test event
sam local invoke CreateOrderFunction \
--event tests/events/api-gateway-post.json \
--env-vars tests/env/local.json
# Invoke with inline event
sam local invoke CreateOrderFunction \
--event '{"body": "{\"product_id\": \"PROD-001\", \"quantity\": 1}"}'
# Invoke with environment overrides
sam local invoke ProcessPaymentFunction \
--event tests/events/sqs-message.json \
--env-vars '{"ProcessPaymentFunction": {"STRIPE_API_KEY": "sk_test_xxx"}}'Create a tests/env/local.json for consistent environment configuration:
{
"CreateOrderFunction": {
"ORDERS_TABLE": "orders-test",
"AWS_ENDPOINT_URL": "http://host.docker.internal:4566",
"LOG_LEVEL": "DEBUG"
},
"ProcessPaymentFunction": {
"STRIPE_API_KEY": "sk_test_placeholder",
"ORDERS_TABLE": "orders-test"
}
}Parse the output in CI:
output=$(sam local invoke CreateOrderFunction --event tests/events/api-gateway-post.json 2>/dev/null)
status_code=$(echo "$output" | python3 -c "import sys, json; print(json.load(sys.stdin)['statusCode'])")
if [ "$status_code" != "201" ]; then
echo "Expected 201, got $status_code"
exit 1
fiSAM Local Start-API
Run a local API Gateway that mirrors your template.yaml routing:
# Start the local API
sam local start-api \
--env-vars tests/env/local.json \
--port 3000 &
# Wait for it to start
sleep 5
# Run API tests against it
pytest tests/integration/api/ -v --base-url http://localhost:3000Integration test against the local API:
# tests/integration/api/test_orders_api.py
import httpx
import pytest
BASE_URL = "http://localhost:3000"
def test_create_order_returns_201():
response = httpx.post(
f"{BASE_URL}/orders",
json={"product_id": "PROD-001", "quantity": 2},
headers={"Content-Type": "application/json"}
)
assert response.status_code == 201
body = response.json()
assert "order_id" in body
assert body["status"] == "pending"
def test_create_order_missing_body_returns_400():
response = httpx.post(f"{BASE_URL}/orders", json={})
assert response.status_code == 400
assert "error" in response.json()
def test_get_nonexistent_order_returns_404():
response = httpx.get(f"{BASE_URL}/orders/nonexistent-id")
assert response.status_code == 404Connecting SAM Local to LocalStack
For tests that need actual AWS service calls, run LocalStack alongside SAM:
# Start LocalStack
docker run --rm -d \
-p 4566:4566 \
-e SERVICES=dynamodb,sqs,s3 \
--name localstack \
localstack/localstack:3.0
# Create test resources
aws --endpoint-url=http://localhost:4566 dynamodb create-table \
--table-name orders-test \
--attribute-definitions AttributeName=order_id,AttributeType=S \
--key-schema AttributeName=order_id,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region us-east-1
# Start SAM with LocalStack endpoints
sam local start-api \
--env-vars tests/env/local.json \
--docker-network host \
--port 3000The --docker-network host flag lets your SAM container reach LocalStack on localhost. On Mac, use host.docker.internal instead:
{
"CreateOrderFunction": {
"AWS_ENDPOINT_URL": "http://host.docker.internal:4566"
}
}Template Validation
Always validate your template.yaml before deploying:
# Validate CloudFormation template
sam validate
# Validate with lint rules
sam validate --lint
# Check for security issues (requires cfn-lint)
pip install cfn-lint
cfn-lint template.yamlAdd to CI:
- name: Validate SAM template
run: |
sam validate --lint
cfn-lint template.yamlTesting Environment Variable Injection
Verify your functions receive correct environment variables by checking them in handler logic:
# handler.py
import os
def lambda_handler(event, context):
# Fail fast if required env vars are missing
required_vars = ["ORDERS_TABLE", "AWS_REGION"]
missing = [v for v in required_vars if not os.getenv(v)]
if missing:
raise RuntimeError(f"Missing required env vars: {missing}")
# ...Test this locally:
# Should fail with missing ORDERS_TABLE
sam local invoke CreateOrderFunction \
--event tests/events/api-gateway-post.json \
--env-vars '{}'
# Should succeed
sam local invoke CreateOrderFunction \
--event tests/events/api-gateway-post.json \
--env-vars '{"CreateOrderFunction": {"ORDERS_TABLE": "orders-test"}}'CI/CD Pipeline Integration
# .github/workflows/test.yml
name: Test SAM Application
on: [push, pull_request]
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pytest boto3 moto
- run: pytest tests/unit/ -v
integration-test:
runs-on: ubuntu-latest
needs: unit-test
steps:
- uses: actions/checkout@v4
- uses: aws-actions/setup-sam@v2
- name: Start LocalStack
run: |
pip install localstack awscli-local
localstack start -d
localstack wait -t 30
- name: Set up test resources
run: |
awslocal dynamodb create-table \
--table-name orders-test \
--attribute-definitions AttributeName=order_id,AttributeType=S \
--key-schema AttributeName=order_id,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region us-east-1
- name: Run integration tests
run: |
sam local start-api \
--env-vars tests/env/local.json \
--port 3000 \
--docker-network host &
sleep 10
pytest tests/integration/ -v --timeout=30Debugging SAM Local
Container pull is slow: SAM pulls the Lambda runtime container on first use. Add --skip-pull-image after the first run to skip re-pulling:
sam local invoke --skip-pull-image CreateOrderFunction --event tests/events/test.jsonFunction times out locally: Default local timeout is 3 seconds. Override with --invoke-timeout:
sam local invoke --invoke-timeout 30 SlowFunction --event tests/events/test.jsonCan't connect to LocalStack: On Mac, use host.docker.internal instead of localhost in endpoint URLs. On Linux with Docker networking, use the container's IP.
Wrong event shape causing KeyError: Use sam local generate-event to generate realistic event shapes:
# Generate API Gateway v2 event
sam local generate-event apigateway http-api-proxy-request
# Generate SQS event
sam local generate-event sqs receive-message
# Generate S3 event
sam local generate-event s3 putSave these as your test event files rather than hand-writing JSON.
SAM Accelerate for Faster Iteration
For active development, sam sync --watch deploys changes to AWS as you save files — much faster than sam deploy for iterative development:
sam sync --watch --stack-name my-service-devThis is for development, not testing. Run your full test suite against sam local before sam sync.
The pattern that works: unit tests catch logic bugs, sam local invoke catches handler/event shape bugs, sam local start-api + LocalStack catches integration bugs, and your actual deploy pipeline catches the remaining IAM/networking issues that only appear in real AWS. Each layer finds different bugs — run all of them.