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:
- Reads your
template.yamlto find the function and its configuration - Pulls the Lambda runtime Docker image (e.g.,
public.ecr.aws/lambda/nodejs20.x) - Mounts your code into the container
- Passes the event payload to the handler
- 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 --versionSAM 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.jsonCheck 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 3000Now 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/abc123Automating 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=30000Hot 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.jsonWith 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_PIDsam 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.jsonThe --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.