AWS SAM Local Testing: Unit, Integration, and End-to-End

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

Keep 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/ -v

Event 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
fi

SAM 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:3000

Integration 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 == 404

Connecting 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 3000

The --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.yaml

Add to CI:

- name: Validate SAM template
  run: |
    sam validate --lint
    cfn-lint template.yaml

Testing 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=30

Debugging 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.json

Function times out locally: Default local timeout is 3 seconds. Override with --invoke-timeout:

sam local invoke --invoke-timeout 30 SlowFunction --event tests/events/test.json

Can'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 put

Save 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-dev

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

Read more

Start now free