Testing AWS Lambda Functions Locally Using SAM

Testing AWS Lambda Functions Locally Using SAM

Testing AWS Lambda functions locally can be a crucial step in the development process, as it allows developers to identify and resolve issues before deploying their code to the cloud. In this regard, AWS SAM provides a method for testing our API called sam local, which enables us to perform tasks such as:

  • Run AWS Lambda functions locally.

  • Test API Gateway endpoints.

  • Simulate event triggers from services like S3, SNS, and SQS.

  • And even debug AWS Lambda functions.

All this works using Docker containers to replicate the AWS runtime environment on your local machine. When we issue a sam local command, SAM reads your template file, pulls the appropriate Docker image for the specified Lambda runtime, then packages our code, dependencies, and environment variables into a Docker container, simulating the actual AWS Lambda environment as closely as possible.

Pre-requisites

  • Ensure you have an IAM User with programmatic access.

  • Install the Amazon Lambda Templates with this command: dotnet new -i Amazon.Lambda.Templates

  • Install the Amazon Lambda Tools with this command: dotnet tool install -g Amazon.Lambda.Tools

  • Install the AWS SAM CLI.

  • Ensure that Docker Desktop is up and running.

The Lambda Function

Run the following commands to create the project that will host our Lambda functions:

dotnet new lambda.EmptyFunction -n MyLambdaFunctions -o .
dotnet add src/MyLambdaFunctions package Amazon.Lambda.APIGatewayEvents
dotnet add src/MyLambdaFunctions package Amazon.Lambda.SQSEvents
dotnet new sln -n SamLocal
dotnet sln add --in-root src/MyLambdaFunctions

Open the solution, navigate to the MyLambdaFunctions project, and update the Function.cs file as follows:

using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Core;
using Amazon.Lambda.SQSEvents;

[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
namespace MyLambdaFunctions;

public class Function
{
    public string FunctionHandler(string input, ILambdaContext context)
    {
        context.Logger.LogInformation($"new message: {input}");

        return "Hello world";
    }

    public APIGatewayHttpApiV2ProxyResponse ApiFunctionHandler(APIGatewayHttpApiV2ProxyRequest input, ILambdaContext context)
    {
        context.Logger.LogInformation($"new message: {input.Body}");

        return new APIGatewayHttpApiV2ProxyResponse
        {
            Body = @"{""Message"":""Hello World""}",
            StatusCode = 200,
            Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
        };
    }

    public void MessageFunctionHandler(SQSEvent evnt, ILambdaContext context)
    {
        foreach (var record in evnt.Records)
        {
            context.Logger.LogInformation($"new message: {record.Body}");
        }
    }
}

We are defining three Lambda functions, two of which have a request and a message as triggers. Create a template.yml file with the following content:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  SAM Local

Resources:
  MyFunction:
    Type: AWS::Serverless::Function
    Properties:
      Timeout: 60
      MemorySize: 512
      Tracing: Active
      Runtime: dotnet6
      Architectures:
        - x86_64    
      Handler: MyLambdaFunctions::MyLambdaFunctions.Function::FunctionHandler
      CodeUri: ./src/MyLambdaFunctions/

  MyApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      Timeout: 60
      MemorySize: 512
      Tracing: Active
      Runtime: dotnet6
      Architectures:
        - x86_64    
      Handler: MyLambdaFunctions::MyLambdaFunctions.Function::ApiFunctionHandler
      CodeUri: ./src/MyLambdaFunctions/
      Events:
        ListPosts:
          Type: Api
          Properties:
            Path: /api
            Method: get

  MyMessageFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: dotnet6
      Handler: MyLambdaFunctions::MyLambdaFunctions.Function::MessageFunctionHandler
      CodeUri: ./src/MyLambdaFunctions/
      Policies:  
        - SQSPollerPolicy:
            QueueName: !GetAtt SQSQueue.QueueName
      Events:
        SQSEvent:
          Type: SQS
          Properties:
            Queue: !GetAtt SQSQueue.Arn
            BatchSize: 10

  SQSQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: "mySQSQueue"

Run sam build to build the resources declared in the template. This must be done every time changes are made.

Invoke a Lambda Function Locally

The standard syntax of the command is:

sam local invoke <functionlogicalid> --event <file>

Create a folder named events and create a file called myfunction.txt with the following content:

"My lambda function input"

Run sam local invoke MyFunction --event .\events\myfunction.txt to see the following output:

START RequestId: a996bd93-68f1-4011-867d-9646ec188542 Version: $LATEST
2023-08-21T00:52:01.317Z        a996bd93-68f1-4011-867d-9646ec188542    info    new message: My lambda function input
END RequestId: a996bd93-68f1-4011-867d-9646ec188542
REPORT RequestId: a996bd93-68f1-4011-867d-9646ec188542  Init Duration: 0.09 ms  Duration: 195.77 ms     Billed Duration: 196 ms Memory Size: 512 MB   Max Memory Used: 512 MB
"Hello world"

Generate an Event to Invoke a Lambda Function

We can use sam local generate-event command to generate events for supported AWS services. We can run sam local generate-event to display the list of supported AWS services. The standard syntax of the command is:

sam local generate-event <service> <event>

For example, by running the command sam local generate-event apigateway -h, we can see all the events available for the API Gateway service:

Options:
  -h, --help  Show this message and exit.

Commands:
  authorizer          Generates an Amazon API Gateway Authorizer Event
  aws-proxy           Generates an Amazon API Gateway AWS Proxy Event
  http-api-proxy      Generates an Amazon API Gateway Http API Event
  request-authorizer  Generates an Amazon API Gateway Request Authorizer Event

And, by running sam local generate-event apigateway http-api-proxy -h, we can see different sections of the event that can be modified:

Options:
  --body TEXT         Specify the body name you'd like, otherwise the default
                      = {"test":"body"}
  --stage TEXT        Specify the stage name you'd like, otherwise the default
                      = $default
...

Run sam local generate-event apigateway http-api-proxy > .\events\myapifunction.json and ensure the generated file is in UTF-8 encoding. Now, run sam local invoke MyApiFunction --event .\events\myapifunction.json to see the following output:

START RequestId: 40b6d72f-6c3d-4e05-ac1c-9f8d94a5662c Version: $LATEST
2023-08-21T01:19:15.296Z        40b6d72f-6c3d-4e05-ac1c-9f8d94a5662c    info    new message: eyJ0ZXN0IjoiYm9keSJ9
END RequestId: 40b6d72f-6c3d-4e05-ac1c-9f8d94a5662c
REPORT RequestId: 40b6d72f-6c3d-4e05-ac1c-9f8d94a5662c  Init Duration: 0.19 ms  Duration: 248.69 ms     Billed Duration: 249 ms Memory Size: 512 MB   Max Memory Used: 512 MB
{"statusCode":200,"headers":{"Content-Type":"application/json"},"body":"{{\"Message\":\"Hello World\"}}","isBase64Encoded":false}

The same exercise can be performed for our third Lambda function, run sam local generate-event sqs receive-message --body "Hi" > .\events\mymessagefunction.json and then sam local invoke MyMessageFunction --event .\events\mymessagefunction.json:

Mounting C:\Source\sam-local\.aws-sam\build\MyMessageFunction as /var/task:ro,delegated inside runtime container
START RequestId: ab673232-20c0-407d-8412-c49213b48566 Version: $LATEST
2023-08-21T01:26:22.898Z        ab673232-20c0-407d-8412-c49213b48566    info    new message: Hi
END RequestId: ab673232-20c0-407d-8412-c49213b48566
REPORT RequestId: ab673232-20c0-407d-8412-c49213b48566  Init Duration: 0.26 ms  Duration: 187.51 ms     Billed Duration: 188 ms Memory Size: 128 MB   Max Memory Used: 128 MB

Starting a Local API Gateway

The standard syntax of the command is:

sam local start-api <options>

One of the most useful options is --warm-containers:

  • eager: Containers for all functions are loaded at startup and persist between invocations.

  • lazy: Containers are only loaded when each function is first invoked and persisted for additional invocations.

Without this option, the command will create a new container each time our function is invoked. Run sam local start-api --warm-containers lazy and invoke the Lambda function through the browser:

All the code is available here. Thanks, and happy coding.