Local Lambda Testing with AWS SAM CLI

Local Lambda Testing with AWS SAM CLI

AWS SAM CLI lets you invoke Lambda handlers locally with sam local invoke and spin up a local API Gateway with sam local start-api. Use it for fast feedback on handler logic and event parsing before deploying to AWS. Combine with environment variable overrides and Docker for CI integration.

Unit tests with mocked AWS SDK calls are fast, but they don't tell you whether your SAM template is configured correctly, whether your handler reads environment variables the way you think, or whether API Gateway maps the HTTP request to the event shape your code expects. That's where AWS SAM CLI fills the gap.

SAM CLI runs your Lambda code inside a Docker container that mirrors the actual Lambda execution environment — same runtime, same directory structure, same invocation contract. No deploy required, no AWS costs, feedback in seconds.

What SAM CLI Actually Does

When you run sam local invoke, SAM CLI:

  1. Reads your template.yaml to find the function and its configuration
  2. Pulls the Lambda runtime Docker image (e.g., public.ecr.aws/lambda/nodejs20.x)
  3. Mounts your code into the container
  4. Passes the event payload to the handler
  5. Returns the response and logs

For sam local start-api, it additionally starts an HTTP server that translates incoming requests into API Gateway proxy events and routes them to the appropriate Lambda function.

This is meaningfully different from unit tests. The code runs in the actual Lambda runtime — so if your dependency has a native binary compiled for the wrong architecture, you'll catch it here rather than in production.

Prerequisites

# Install SAM CLI
brew install aws-sam-cli  # macOS
pip install aws-sam-cli   # Linux/Windows

# Docker is required for local invocation
docker --version

# Verify SAM installation
sam --version

SAM Template Structure

A minimal template.yaml for a REST API:

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Runtime: nodejs20.x
    Timeout: 30
    MemorySize: 256
    Environment:
      Variables:
        TABLE_NAME: !Ref ItemsTable
        LOG_LEVEL: info

Resources:
  CreateItemFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/handlers/create-item.handler
      Events:
        CreateItem:
          Type: Api
          Properties:
            Path: /items
            Method: post

  GetItemFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/handlers/get-item.handler
      Events:
        GetItem:
          Type: Api
          Properties:
            Path: /items/{id}
            Method: get

  ItemsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: items
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH
      BillingMode: PAY_PER_REQUEST

Outputs:
  ApiUrl:
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"

sam local invoke for Unit-Style Local Testing

sam local invoke runs a single Lambda function with a single event payload and exits. It's the closest thing to a unit test in the SAM CLI world.

Create event files for each scenario you want to test:

// events/create-item-valid.json
{
  "httpMethod": "POST",
  "path": "/items",
  "headers": {
    "Content-Type": "application/json"
  },
  "body": "{\"id\": \"abc123\", \"name\": \"Widget\", \"price\": 9.99}",
  "requestContext": {
    "requestId": "local-test-001"
  }
}
// events/create-item-missing-field.json
{
  "httpMethod": "POST",
  "path": "/items",
  "headers": {
    "Content-Type": "application/json"
  },
  "body": "{\"name\": \"Widget\"}",
  "requestContext": {
    "requestId": "local-test-002"
  }
}

Invoke the function:

sam local invoke CreateItemFunction \
  --event events/create-item-valid.json \
  --env-vars env.json

Check the exit code and parse the output:

RESPONSE=$(sam local invoke CreateItemFunction --event events/create-item-valid.json 2>/dev/null)
STATUS=$(echo "$RESPONSE" | jq -r '.statusCode')
if [ "$STATUS" != "201" ]; then
  echo "FAIL: expected 201, got $STATUS"
  exit 1
fi
echo "PASS"

Environment Variable Handling

Lambda functions depend heavily on environment variables. SAM CLI gives you two ways to manage them for local testing.

Option 1: env.json file

// env.json
{
  "CreateItemFunction": {
    "TABLE_NAME": "local-items-table",
    "LOG_LEVEL": "debug",
    "REGION": "us-east-1"
  },
  "GetItemFunction": {
    "TABLE_NAME": "local-items-table",
    "LOG_LEVEL": "debug",
    "REGION": "us-east-1"
  }
}

Pass it with --env-vars env.json. This file should be in .gitignore for anything containing secrets — use a template env.json.example instead.

Option 2: --parameter-overrides

sam local invoke CreateItemFunction \
  --event events/create-item-valid.json \
  --parameter-overrides "TableName=local-test-table LogLevel=debug"

Option 3: Docker network + LocalStack

For full local AWS simulation, point your Lambda at LocalStack:

// env.json (LocalStack variant)
{
  "CreateItemFunction": {
    "TABLE_NAME": "items",
    "AWS_ENDPOINT_URL": "http://host.docker.internal:4566",
    "AWS_DEFAULT_REGION": "us-east-1",
    "AWS_ACCESS_KEY_ID": "test",
    "AWS_SECRET_ACCESS_KEY": "test"
  }
}

sam local start-api for API Gateway Testing

sam local start-api starts an HTTP server on port 3000 (by default) that behaves like API Gateway:

sam local start-api --env-vars env.json --port 3000

Now you can make real HTTP requests:

# Happy path
curl -X POST http://localhost:3000/items \
  -H "Content-Type: application/json" \
  -d '{"id": "abc123", "name": "Widget", "price": 9.99}'

# Missing required field
curl -X POST http://localhost:3000/items \
  -H "Content-Type: application/json" \
  -d '{"name": "Widget"}'

# GET by ID
curl http://localhost:3000/items/abc123

Automating API Tests Against the Local Server

Write a shell script or use a tool like httpie or jest with axios to run a suite against the local server:

// api.test.js (runs against local SAM server)
const axios = require("axios");

const BASE_URL = "http://localhost:3000";

describe("Items API (local SAM)", () => {
  test("POST /items returns 201 for valid payload", async () => {
    const res = await axios.post(`${BASE_URL}/items`, {
      id: "test-001",
      name: "Widget",
      price: 9.99,
    });
    expect(res.status).toBe(201);
  });

  test("POST /items returns 400 for missing id", async () => {
    await expect(
      axios.post(`${BASE_URL}/items`, { name: "Widget" })
    ).rejects.toMatchObject({ response: { status: 400 } });
  });

  test("GET /items/:id returns 404 for unknown id", async () => {
    await expect(
      axios.get(`${BASE_URL}/items/does-not-exist`)
    ).rejects.toMatchObject({ response: { status: 404 } });
  });
});

Run with Jest while the SAM server is running:

# Terminal 1
sam local start-api --env-vars env.json

# Terminal 2
npx jest api.test.js --testTimeout=30000

Hot Reloading for Faster Iteration

By default, SAM CLI rebuilds the Docker container for every invocation. For interpreted languages (Python, Node.js), use --warm-containers to keep the container alive:

sam local start-api --warm-containers EAGER --env-vars env.json

With EAGER, all functions start containers on server start. With LAZY, containers start on first request. Either option dramatically speeds up iteration during active development.

CI Integration for SAM Local Tests

Here's a GitHub Actions workflow that runs SAM local tests in CI:

# .github/workflows/local-tests.yml
name: SAM Local Tests

on: [push, pull_request]

jobs:
  local-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install SAM CLI
        run: pip install aws-sam-cli

      - name: Install dependencies
        run: npm ci

      - name: Build SAM application
        run: sam build

      - name: Run invoke tests
        run: |
          sam local invoke CreateItemFunction \
            --event events/create-item-valid.json \
            --env-vars env.test.json \
            --no-event 2>/dev/null || true
          
          RESULT=$(sam local invoke CreateItemFunction \
            --event events/create-item-valid.json \
            --env-vars env.test.json 2>/dev/null)
          
          STATUS=$(echo "$RESULT" | jq -r '.statusCode')
          test "$STATUS" = "201" || (echo "Expected 201, got $STATUS" && exit 1)

      - name: Start SAM API and run integration tests
        run: |
          sam local start-api --env-vars env.test.json &
          SAM_PID=$!
          
          # Wait for server to be ready
          for i in {1..30}; do
            curl -sf http://localhost:3000/ && break || sleep 1
          done
          
          npx jest api.test.js --testTimeout=30000
          
          kill $SAM_PID

sam build Before Local Testing

If your project uses TypeScript, esbuild, or any build step, always run sam build before sam local invoke:

sam build --use-container  # Build inside Lambda runtime container
sam local invoke CreateItemFunction --event events/create-item-valid.json

The --use-container flag ensures the build environment matches the runtime — critical for packages with native binaries.

Common Pitfalls

Cold start on every invocation. Without --warm-containers, each sam local invoke starts fresh. Add --warm-containers EAGER to start-api for iterative work.

Docker networking. Lambda containers can't reach localhost on the host machine. Use host.docker.internal (macOS/Windows) or 172.17.0.1 (Linux) to reach services like LocalStack running on the host.

Port conflicts. SAM uses port 3000 by default. Pass --port 3001 if something else is already there.

Missing sam build. TypeScript handlers fail silently if you invoke the source .ts file without building first.

Summary

SAM CLI is the bridge between unit tests and real AWS. Use sam local invoke to test individual handlers with specific event payloads, and sam local start-api to test your full API surface locally before deploying. Combine with an env.json file for environment variable management and Docker networking to reach LocalStack for full AWS simulation. Add both invocation tests and API tests to CI for a fast, cost-free feedback loop.

Read more

Start now free