AWS Lambda Testing Guide: Unit and Integration Tests with Jest and pytest

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:

  1. Environment dependencies — functions depend on AWS services (DynamoDB, S3, SQS) that aren't available locally without setup
  2. Event-driven inputs — handlers receive complex event objects (API Gateway events, SQS records, S3 notifications) that need to be constructed for tests
  3. Cold start state — initialization code runs once per container, not per invocation
  4. 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.js

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

Unit 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 None

Handler 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"] == 500

Testing 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: orders

Summary

Test Lambda functions in three layers:

  1. Business logic unit tests — pure function tests with Jest or pytest, no AWS SDK
  2. Handler tests — mock the service layer, verify event parsing and response formatting
  3. 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.

Read more