AWS Lambda Testing Guide: Unit and Integration Tests with Jest and pytest
Testing Lambda functions requires separating business logic from the Lambda handler. Unit-test the business logic with Jest or pytest (no AWS SDK needed), integration-test the handler against LocalStack or real AWS, and mock AWS SDK calls using jest.mock() or moto. The key insight: the Lambda handler is a thin wrapper around testable business logic.
Key Takeaways
Separate handler from business logic. The handler function (exports.handler / lambda_handler) should do nothing but parse the event, call your business logic, and format the response. Test the business logic independently.
Mock AWS SDK calls in unit tests. Use jest.mock() for Node.js or moto for Python. This makes tests fast and deterministic without requiring AWS credentials.
Use LocalStack for integration tests. LocalStack runs AWS services locally in Docker. Write integration tests that use the real AWS SDK against LocalStack instead of mocking.
Test the event shape, not AWS internals. Lambda functions receive events with specific shapes (API Gateway, SQS, S3, etc.). Test that your handler correctly parses these shapes—wrong event parsing is the most common Lambda bug.
Test cold start behavior separately. Lambda initializes global state once per container. Test that your initialization logic (database connections, config loading) works correctly and doesn't cause issues on cold starts.
Why Lambda Testing Is Different
AWS Lambda functions have unique testing challenges:
- Environment dependencies — functions depend on AWS services (DynamoDB, S3, SQS) that aren't available locally without setup
- Event-driven inputs — handlers receive complex event objects (API Gateway events, SQS records, S3 notifications) that need to be constructed for tests
- Cold start state — initialization code runs once per container, not per invocation
- Timeout constraints — functions have execution time limits that don't exist in regular application code
The solution: unit-test your business logic in isolation, integration-test the handler with mocked or real AWS services.
Node.js Lambda Testing with Jest
Project Structure
lambda/
src/
orders/
handler.js # Lambda handler (thin wrapper)
service.js # Business logic
repository.js # DynamoDB operations
tests/
unit/
service.test.js
integration/
handler.test.js
jest.config.jsThe Handler Pattern
Write handlers as thin wrappers around testable services:
// src/orders/handler.js
const { createOrder, getOrder } = require('./service');
exports.handler = async (event) => {
try {
const { httpMethod, pathParameters, body } = event;
if (httpMethod === 'POST') {
const orderData = JSON.parse(body);
const order = await createOrder(orderData);
return {
statusCode: 201,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
};
}
if (httpMethod === 'GET') {
const { orderId } = pathParameters;
const order = await getOrder(orderId);
if (!order) {
return { statusCode: 404, body: JSON.stringify({ error: 'Not found' }) };
}
return { statusCode: 200, body: JSON.stringify(order) };
}
return { statusCode: 405, body: JSON.stringify({ error: 'Method not allowed' }) };
} catch (error) {
console.error('Handler error:', error);
return { statusCode: 500, body: JSON.stringify({ error: 'Internal server error' }) };
}
};// src/orders/service.js
const { saveOrder, findOrderById } = require('./repository');
const { v4: uuidv4 } = require('uuid');
async function createOrder({ customerId, items }) {
if (!customerId) throw new Error('customerId is required');
if (!items || items.length === 0) throw new Error('items cannot be empty');
const order = {
orderId: uuidv4(),
customerId,
items,
total: items.reduce((sum, item) => sum + item.price * item.quantity, 0),
status: 'pending',
createdAt: new Date().toISOString(),
};
await saveOrder(order);
return order;
}
async function getOrder(orderId) {
return findOrderById(orderId);
}
module.exports = { createOrder, getOrder };Unit Tests for Business Logic
// tests/unit/service.test.js
const { createOrder, getOrder } = require('../../src/orders/service');
// Mock the repository
jest.mock('../../src/orders/repository', () => ({
saveOrder: jest.fn().mockResolvedValue(undefined),
findOrderById: jest.fn(),
}));
const { saveOrder, findOrderById } = require('../../src/orders/repository');
describe('createOrder', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('creates order with calculated total', async () => {
const input = {
customerId: 'cust-123',
items: [
{ productId: 'prod-1', quantity: 2, price: 50.00 },
{ productId: 'prod-2', quantity: 1, price: 30.00 },
],
};
const order = await createOrder(input);
expect(order.customerId).toBe('cust-123');
expect(order.total).toBe(130.00);
expect(order.status).toBe('pending');
expect(order.orderId).toBeDefined();
expect(saveOrder).toHaveBeenCalledWith(expect.objectContaining({
customerId: 'cust-123',
total: 130.00,
}));
});
test('throws when customerId is missing', async () => {
await expect(createOrder({ items: [{ productId: 'p1', quantity: 1, price: 10 }] }))
.rejects.toThrow('customerId is required');
});
test('throws when items is empty', async () => {
await expect(createOrder({ customerId: 'cust-123', items: [] }))
.rejects.toThrow('items cannot be empty');
});
});
describe('getOrder', () => {
test('returns order when found', async () => {
const mockOrder = { orderId: 'ord-123', customerId: 'cust-456', status: 'pending' };
findOrderById.mockResolvedValue(mockOrder);
const result = await getOrder('ord-123');
expect(result).toEqual(mockOrder);
expect(findOrderById).toHaveBeenCalledWith('ord-123');
});
test('returns null when order not found', async () => {
findOrderById.mockResolvedValue(null);
const result = await getOrder('nonexistent');
expect(result).toBeNull();
});
});Handler Tests with Event Fixtures
// tests/unit/handler.test.js
jest.mock('../../src/orders/service');
const { createOrder, getOrder } = require('../../src/orders/service');
const { handler } = require('../../src/orders/handler');
// API Gateway event factory
const makeApiGatewayEvent = (method, body = null, pathParameters = {}) => ({
httpMethod: method,
pathParameters,
body: body ? JSON.stringify(body) : null,
headers: {},
queryStringParameters: {},
});
describe('POST /orders', () => {
test('returns 201 with created order', async () => {
const mockOrder = { orderId: 'ord-123', total: 130 };
createOrder.mockResolvedValue(mockOrder);
const event = makeApiGatewayEvent('POST', {
customerId: 'cust-123',
items: [{ productId: 'p1', quantity: 1, price: 130 }],
});
const response = await handler(event);
expect(response.statusCode).toBe(201);
expect(JSON.parse(response.body)).toEqual(mockOrder);
});
test('returns 500 when service throws', async () => {
createOrder.mockRejectedValue(new Error('Database error'));
const event = makeApiGatewayEvent('POST', { customerId: 'cust-123', items: [] });
const response = await handler(event);
expect(response.statusCode).toBe(500);
});
});
describe('GET /orders/:orderId', () => {
test('returns 200 with order', async () => {
const mockOrder = { orderId: 'ord-123' };
getOrder.mockResolvedValue(mockOrder);
const event = makeApiGatewayEvent('GET', null, { orderId: 'ord-123' });
const response = await handler(event);
expect(response.statusCode).toBe(200);
expect(JSON.parse(response.body)).toEqual(mockOrder);
});
test('returns 404 when order not found', async () => {
getOrder.mockResolvedValue(null);
const event = makeApiGatewayEvent('GET', null, { orderId: 'nonexistent' });
const response = await handler(event);
expect(response.statusCode).toBe(404);
});
});Python Lambda Testing with pytest
Project Structure
lambda/
orders/
__init__.py
handler.py
service.py
repository.py
tests/
conftest.py
test_service.py
test_handler.py
test_repository.py
requirements.txt
requirements-test.txtUnit Tests with moto
moto mocks the AWS SDK for Python (boto3):
# tests/test_repository.py
import pytest
import boto3
from moto import mock_dynamodb
from orders.repository import save_order, find_order_by_id
@mock_dynamodb
class TestOrderRepository:
def setup_method(self):
# Create the DynamoDB table before each test
dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
dynamodb.create_table(
TableName="orders",
KeySchema=[{"AttributeName": "orderId", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "orderId", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
def test_save_and_retrieve_order(self):
order = {
"orderId": "ord-123",
"customerId": "cust-456",
"total": 150.00,
"status": "pending",
}
save_order(order)
retrieved = find_order_by_id("ord-123")
assert retrieved["orderId"] == "ord-123"
assert retrieved["customerId"] == "cust-456"
assert float(retrieved["total"]) == 150.00
def test_find_nonexistent_order_returns_none(self):
result = find_order_by_id("nonexistent")
assert result is NoneHandler Tests
# tests/test_handler.py
import json
import pytest
from unittest.mock import patch, MagicMock
from orders.handler import lambda_handler
def api_gateway_event(method, body=None, path_params=None):
return {
"httpMethod": method,
"pathParameters": path_params or {},
"body": json.dumps(body) if body else None,
"headers": {},
"queryStringParameters": {},
}
class TestLambdaHandler:
@patch("orders.handler.create_order")
def test_post_creates_order(self, mock_create):
mock_create.return_value = {"orderId": "ord-123", "total": 50.0}
event = api_gateway_event(
"POST",
{"customerId": "cust-1", "items": [{"productId": "p1", "quantity": 1, "price": 50.0}]}
)
response = lambda_handler(event, {})
assert response["statusCode"] == 201
body = json.loads(response["body"])
assert body["orderId"] == "ord-123"
@patch("orders.handler.get_order")
def test_get_returns_order(self, mock_get):
mock_get.return_value = {"orderId": "ord-123", "status": "pending"}
event = api_gateway_event("GET", path_params={"orderId": "ord-123"})
response = lambda_handler(event, {})
assert response["statusCode"] == 200
@patch("orders.handler.get_order")
def test_get_returns_404_when_not_found(self, mock_get):
mock_get.return_value = None
event = api_gateway_event("GET", path_params={"orderId": "nonexistent"})
response = lambda_handler(event, {})
assert response["statusCode"] == 404
@patch("orders.handler.create_order")
def test_post_returns_500_on_error(self, mock_create):
mock_create.side_effect = Exception("Database unavailable")
event = api_gateway_event(
"POST",
{"customerId": "cust-1", "items": [{"productId": "p1", "quantity": 1, "price": 10.0}]}
)
response = lambda_handler(event, {})
assert response["statusCode"] == 500Testing Different Event Types
Lambda functions receive events from many AWS services. Use event fixtures for each:
// tests/fixtures/events.js
const sqsEvent = (body) => ({
Records: [{
messageId: 'msg-123',
receiptHandle: 'receipt-123',
body: typeof body === 'string' ? body : JSON.stringify(body),
attributes: { ApproximateReceiveCount: '1' },
messageAttributes: {},
md5OfBody: 'abc123',
eventSource: 'aws:sqs',
eventSourceARN: 'arn:aws:sqs:us-east-1:123456789:my-queue',
awsRegion: 'us-east-1',
}],
});
const s3Event = (bucket, key) => ({
Records: [{
eventSource: 'aws:s3',
eventName: 'ObjectCreated:Put',
s3: {
bucket: { name: bucket, arn: `arn:aws:s3:::${bucket}` },
object: { key, size: 1024 },
},
}],
});
module.exports = { sqsEvent, s3Event };CI Configuration
# .github/workflows/lambda-tests.yml
name: Lambda Tests
on: [push, pull_request]
jobs:
test:
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 dependencies
run: cd lambda && npm ci
- name: Run unit tests
run: cd lambda && npm test
env:
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
DYNAMODB_TABLE_NAME: ordersSummary
Test Lambda functions in three layers:
- Business logic unit tests — pure function tests with Jest or pytest, no AWS SDK
- Handler tests — mock the service layer, verify event parsing and response formatting
- Repository/AWS integration tests — mock AWS SDK with jest.mock() or moto
Keep handlers thin. All business logic should live in services that can be tested without any AWS context. The handler's only job is to parse the event, call the service, and format the response.