Testing AWS Lambda Functions: Unit, Integration, and End-to-End Patterns
Testing Lambda functions is different from testing traditional services. Your function has no persistent process, it runs in an ephemeral container, and it's tightly coupled to AWS events and services. A function that works locally can fail in production due to IAM permissions, environment variable differences, timeout constraints, or cold start behavior.
This guide covers a layered testing strategy that catches bugs at each level before they reach production.
The Testing Pyramid for Lambda
┌─────────────────┐
│ E2E (deployed) │ ← Deployed function, real AWS events
├─────────────────┤
│ Integration │ ← SAM local, real AWS services (S3, DynamoDB)
├─────────────────┤
│ Unit Tests │ ← Pure business logic, mocked AWS SDK
└─────────────────┘Unit tests are fast and cheap. Integration tests verify AWS service interactions. E2E tests validate the real deployment. All three are necessary.
Unit Testing Lambda Functions
Structure Your Handler for Testability
The key is separating handler logic from business logic:
# handler.py
import json
from services import UserService
# Handler — thin wrapper, hard to unit test directly
def lambda_handler(event, context):
user_id = event['pathParameters']['userId']
service = UserService()
user = service.get_user(user_id)
if not user:
return {'statusCode': 404, 'body': json.dumps({'error': 'User not found'})}
return {'statusCode': 200, 'body': json.dumps(user.to_dict())}# services.py — pure business logic, testable without AWS
class UserService:
def __init__(self, repository=None):
self.repository = repository or DynamoDBUserRepository()
def get_user(self, user_id: str) -> Optional[User]:
if not user_id or not user_id.strip():
raise ValueError("user_id cannot be empty")
return self.repository.find(user_id)Now test UserService independently:
# test_user_service.py
import pytest
from unittest.mock import Mock
from services import UserService, User
def test_get_user_returns_user_when_found():
mock_repo = Mock()
mock_repo.find.return_value = User(id="123", name="Alice")
service = UserService(repository=mock_repo)
result = service.get_user("123")
assert result.name == "Alice"
mock_repo.find.assert_called_once_with("123")
def test_get_user_raises_on_empty_id():
service = UserService(repository=Mock())
with pytest.raises(ValueError, match="cannot be empty"):
service.get_user("")
def test_get_user_returns_none_when_not_found():
mock_repo = Mock()
mock_repo.find.return_value = None
service = UserService(repository=mock_repo)
result = service.get_user("nonexistent")
assert result is NoneTesting the Handler with Mock Events
# test_handler.py
import json
from unittest.mock import Mock, patch
from handler import lambda_handler
def make_event(user_id):
return {
'pathParameters': {'userId': user_id},
'httpMethod': 'GET',
'headers': {}
}
def test_handler_returns_200_for_existing_user():
with patch('handler.UserService') as mock_service_class:
mock_service = Mock()
mock_service.get_user.return_value = Mock(
to_dict=lambda: {'id': '123', 'name': 'Alice'}
)
mock_service_class.return_value = mock_service
response = lambda_handler(make_event('123'), {})
assert response['statusCode'] == 200
body = json.loads(response['body'])
assert body['name'] == 'Alice'
def test_handler_returns_404_for_missing_user():
with patch('handler.UserService') as mock_service_class:
mock_service = Mock()
mock_service.get_user.return_value = None
mock_service_class.return_value = mock_service
response = lambda_handler(make_event('unknown'), {})
assert response['statusCode'] == 404Mocking AWS Services with moto
moto intercepts AWS SDK calls and simulates AWS services in-process:
pip install moto[dynamodb]import boto3
import pytest
from moto import mock_dynamodb
from repositories import DynamoDBUserRepository
@mock_dynamodb
def test_repository_saves_and_retrieves_user():
# Create the table (moto simulates DynamoDB)
dynamodb = boto3.resource('dynamodb', region_name='us-east-1')
dynamodb.create_table(
TableName='users',
KeySchema=[{'AttributeName': 'id', 'KeyType': 'HASH'}],
AttributeDefinitions=[{'AttributeName': 'id', 'AttributeType': 'S'}],
BillingMode='PAY_PER_REQUEST'
)
repo = DynamoDBUserRepository(table_name='users', region='us-east-1')
user = User(id='123', name='Alice', email='alice@example.com')
repo.save(user)
retrieved = repo.find('123')
assert retrieved.name == 'Alice'
assert retrieved.email == 'alice@example.com'
@mock_dynamodb
def test_repository_returns_none_for_missing_user():
dynamodb = boto3.resource('dynamodb', region_name='us-east-1')
dynamodb.create_table(
TableName='users',
KeySchema=[{'AttributeName': 'id', 'KeyType': 'HASH'}],
AttributeDefinitions=[{'AttributeName': 'id', 'AttributeType': 'S'}],
BillingMode='PAY_PER_REQUEST'
)
repo = DynamoDBUserRepository(table_name='users', region='us-east-1')
result = repo.find('nonexistent')
assert result is Nonemoto supports S3, SQS, SNS, Secrets Manager, and most other AWS services.
Integration Testing with AWS SAM Local
AWS SAM Local runs your Lambda functions locally in a Docker container that mimics the Lambda runtime environment. It catches issues that unit tests can't: handler path errors, missing dependencies, runtime version mismatches.
pip install aws-sam-cli# Invoke a function locally with a test event
sam local invoke GetUser --event events/get-user-event.json
# Start a local API Gateway
sam local start-api
# Run a specific function with environment variables
sam local invoke GetUser \
--event events/get-user-event.json \
--env-vars env.jsonCreate test events that match real AWS event shapes:
// events/get-user-event.json
{
"httpMethod": "GET",
"pathParameters": {
"userId": "123"
},
"headers": {
"Authorization": "Bearer test-token"
},
"requestContext": {
"authorizer": {
"claims": {
"sub": "user-123"
}
}
}
}Automate SAM local invocations in tests:
import subprocess
import json
import pytest
def invoke_sam_local(function_name, event):
"""Invoke a Lambda locally via SAM CLI"""
import tempfile
import os
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(event, f)
event_file = f.name
try:
result = subprocess.run(
['sam', 'local', 'invoke', function_name, '--event', event_file],
capture_output=True,
text=True,
timeout=30
)
return json.loads(result.stdout)
finally:
os.unlink(event_file)
def test_get_user_sam_local():
event = {
'httpMethod': 'GET',
'pathParameters': {'userId': '123'}
}
response = invoke_sam_local('GetUser', event)
assert response['statusCode'] == 200
body = json.loads(response['body'])
assert 'id' in bodyTesting Event Sources
Each event source produces a different event shape. Test against realistic event structures.
SQS Trigger
def test_process_sqs_messages():
event = {
'Records': [
{
'messageId': 'msg-1',
'body': json.dumps({'orderId': 'order-123', 'total': 99.99}),
'attributes': {
'ApproximateReceiveCount': '1'
},
'receiptHandle': 'handle-1',
'eventSource': 'aws:sqs'
},
{
'messageId': 'msg-2',
'body': json.dumps({'orderId': 'order-456', 'total': 49.99}),
'attributes': {
'ApproximateReceiveCount': '1'
},
'receiptHandle': 'handle-2',
'eventSource': 'aws:sqs'
}
]
}
with patch('handler.OrderService') as mock_service_class:
mock_service = Mock()
mock_service_class.return_value = mock_service
result = process_orders_handler(event, {})
assert mock_service.process.call_count == 2
def test_partial_batch_failure():
"""Test that failed messages are returned for retry"""
event = {'Records': [
{'messageId': 'msg-1', 'body': json.dumps({'orderId': 'valid'})},
{'messageId': 'msg-2', 'body': 'invalid-json-that-will-fail'}
]}
result = process_orders_handler(event, {})
# Handler should report msg-2 as failed (partial batch response)
assert 'batchItemFailures' in result
failed_ids = [f['itemIdentifier'] for f in result['batchItemFailures']]
assert 'msg-2' in failed_ids
assert 'msg-1' not in failed_ids # successful message should not be retriedS3 Event
def test_process_s3_upload():
event = {
'Records': [{
'eventSource': 'aws:s3',
'eventName': 'ObjectCreated:Put',
's3': {
'bucket': {'name': 'my-upload-bucket'},
'object': {
'key': 'uploads/image.jpg',
'size': 1024
}
}
}]
}
with patch('handler.ImageProcessor') as mock_processor:
mock_instance = Mock()
mock_processor.return_value = mock_instance
image_processor_handler(event, {})
mock_instance.process.assert_called_once_with(
bucket='my-upload-bucket',
key='uploads/image.jpg'
)End-to-End Testing Against Deployed Functions
E2E tests invoke the real deployed function to catch IAM permission issues, environment variable problems, and integration failures:
import boto3
import json
import pytest
# Only run E2E tests when explicitly enabled
@pytest.mark.skipif(
os.environ.get('RUN_E2E_TESTS') != 'true',
reason="E2E tests require deployed Lambda and AWS credentials"
)
def test_deployed_get_user():
lambda_client = boto3.client('lambda', region_name='us-east-1')
payload = {
'httpMethod': 'GET',
'pathParameters': {'userId': 'test-user-e2e'},
'headers': {}
}
response = lambda_client.invoke(
FunctionName='GetUser-production',
InvocationType='RequestResponse',
Payload=json.dumps(payload)
)
result = json.loads(response['Payload'].read())
assert response['StatusCode'] == 200
assert result['statusCode'] in [200, 404] # valid response codes
# Verify no function error
assert 'FunctionError' not in responseRun E2E tests after deployment in CI:
- name: Deploy
run: sam deploy --no-confirm-changeset
- name: Run E2E tests
env:
RUN_E2E_TESTS: "true"
run: pytest tests/e2e/Testing Timeouts and Cold Starts
Lambda has a maximum execution timeout. Test that your function handles long operations within limits:
import time
def test_function_completes_within_timeout():
"""Verify handler completes well within Lambda timeout"""
event = make_large_event() # worst-case payload
start = time.time()
result = lambda_handler(event, Mock(remaining_time_in_millis=lambda: 30000))
elapsed = time.time() - start
# Should complete within 10 seconds (Lambda timeout is 30s)
assert elapsed < 10, f"Handler took {elapsed:.2f}s, too slow"
assert result['statusCode'] == 200Lambda function testing requires discipline at all three levels: fast unit tests for logic, SAM local for runtime environment validation, and E2E for deployment verification. Skip any layer and you'll ship bugs that only appear in production.
Lambda functions are one part of your stack. For monitoring deployed serverless functions, detecting endpoint regressions, and 24/7 uptime checks, HelpMeTest covers the full application layer with usage-based pricing starting at $0.003 per test run.