MartinCostello.Testing.AwsLambdaTestServer 0.8.0

dotnet add package MartinCostello.Testing.AwsLambdaTestServer --version 0.8.0
NuGet\Install-Package MartinCostello.Testing.AwsLambdaTestServer -Version 0.8.0
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MartinCostello.Testing.AwsLambdaTestServer" Version="0.8.0" />
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add MartinCostello.Testing.AwsLambdaTestServer --version 0.8.0
#r "nuget: MartinCostello.Testing.AwsLambdaTestServer, 0.8.0"
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install MartinCostello.Testing.AwsLambdaTestServer as a Cake Addin
#addin nuget:?package=MartinCostello.Testing.AwsLambdaTestServer&version=0.8.0

// Install MartinCostello.Testing.AwsLambdaTestServer as a Cake Tool
#tool nuget:?package=MartinCostello.Testing.AwsLambdaTestServer&version=0.8.0

AWS Lambda Test Server for .NET

NuGet

Build status codecov OpenSSF Scorecard

Introduction

A NuGet package that builds on top of the TestServer class in the Microsoft.AspNetCore.TestHost NuGet package to provide infrastructure to use with end-to-end/integration tests of .NET 6 AWS Lambda Functions using a custom runtime with the LambdaBootstrap class from the Amazon.Lambda.RuntimeSupport NuGet package.

.NET Core 3.0 on Lambda with AWS Lambda's Custom Runtime

Installation

To install the library from NuGet using the .NET SDK run:

dotnet add package MartinCostello.Testing.AwsLambdaTestServer

Usage

Before you can use the Lambda test server to test your function, you need to factor your function entry-point in such a way that you can supply both a HttpClient and CancellationToken to it from your tests. This is to allow you to both plug in the HttpClient for the test server into LambdaBootstrap, and to stop the Lambda function running at a time of your choosing by signalling the CancellationToken.

Here's an example of how to do this with a simple Lambda function that takes an array of integers and returns them in reverse order:

using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.Json;

namespace MyFunctions;

public static class ReverseFunction
{
    public static async Task Main()
        => await RunAsync();

    public static async Task RunAsync(
        HttpClient httpClient = null,
        CancellationToken cancellationToken = default)
    {
        var serializer = new JsonSerializer();

        using var handlerWrapper = HandlerWrapper.GetHandlerWrapper<int[], int[]>(ReverseAsync, serializer);
        using var bootstrap = new LambdaBootstrap(httpClient ?? new HttpClient(), handlerWrapper);

        await bootstrap.RunAsync(cancellationToken);
    }

    public static Task<int[]> ReverseAsync(int[] values)
    {
        return Task.FromResult(values.Reverse().ToArray());
    }
}

Once you've done that, you can use LambdaTestServer in your tests with your function to verify how it processes requests.

Here's an example using xunit to verify that ReverseFunction works as intended:

using System;
using System.Threading;
using System.Threading.Tasks;
using MartinCostello.Testing.AwsLambdaTestServer;
using Newtonsoft.Json;
using Xunit;

namespace MyFunctions;

public static class ReverseFunctionTests
{
    [Fact]
    public static async Task Function_Reverses_Numbers()
    {
        // Arrange
        using var server = new LambdaTestServer();
        using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(1));

        await server.StartAsync(cancellationTokenSource.Token);

        int[] value = new[] { 1, 2, 3 };
        string json = JsonConvert.SerializeObject(value);

        LambdaTestContext context = await server.EnqueueAsync(json);

        using var httpClient = server.CreateClient();

        // Act
        await ReverseFunction.RunAsync(httpClient, cancellationTokenSource.Token);

        // Assert
        Assert.True(context.Response.TryRead(out LambdaTestResponse response));
        Assert.True(response.IsSuccessful);

        json = await response.ReadAsStringAsync();
        int[] actual = JsonConvert.DeserializeObject<int[]>(json);

        Assert.Equal(new[] { 3, 2, 1 }, actual);
    }
}

The key parts to call out here are:

  1. An instance of LambdaTestServer is created and then the StartAsync() method called with a CancellationToken that allows the test to stop the function. In the example here the token is signalled with a timeout, but you could also write code to stop the processing based on arbitrary criteria.
  2. The request that the Lambda function should be invoked with is passed to EnqueueAsync(). This can be specified with an instance of LambdaTestRequest for fine-grained control, but there are overloads that accept byte[] and string. You could also make your own extensions to serialize objects to JSON using the serializer of your choice.
  3. EnqueueAsync() returns a LambdaTestContext. This contains a reference to the LambdaTestRequest and a ChannelReader<LambdaTestResponse>. This channel reader can be used to await the request being processed by the function under test.
  4. Once the request is enqueued, an HttpClient is obtained from the test server and passed to the function to test with the cancellation token and run by calling RunAsync().
  5. Once the function processing completes after the CancellationToken is signalled, the channel reader is read to obtain the LambdaTestResponse for the request that was enqueued.
  6. Once this is returned from the channel reader, the response is checked for success using IsSuccessful and then the Content (which is a byte[]) is deserialized into the expected response to be asserted on. Again, you could make your own extensions to deserialize the response content into string or objects from JSON.

The library itself targets net6.0, net7.0 and net8.0 so requires your test project to target at least .NET 6.

Sequence Diagram

The sequence diagram below illustrates the flow of events for a test using the test server for the above example.

sequenceDiagram
    autonumber

    participant T as Test Method
    participant S as Lambda Test Server
    participant F as Lambda Function
    participant H as Handler

    title How AWS Lambda Test Server Works

    note over T:Arrange

    T->>+S: Start test server

    S->>S:Start HTTP server

    S-->>T: 

    T->>S:Queue request

    note over S:Request is queued

    S-->>T:LambdaTestContext

    T->>+F:Create function with HttpClient for Test Server

    note over T:Act

    note over T:Wait for request(s)<br/>to be handled

    loop Poll for Lambda invocations

        F->>S:GET /{LambdaVersion}/runtime/invocation/next

        note over S:Request is dequeued

        S-->>F:HTTP 200

        F->>+H:Invoke Handler

        note over H:System Under Test

        H-->>-F:Response

        alt Invocation is handled successfully

        F->>S:POST /{LambdaVersion}/runtime/invocation/{AwsRequestId}/response

        else Invocation throws an exception

        F->>S:POST /{LambdaVersion}/runtime/invocation/{AwsRequestId}/error

        end

        note over S:Associate response with<br/>LambdaTestContext

        S-)T:Signal request handled<br/>on LambdaTestContext

        S-->>F:HTTP 204

        T->>F:Stop Lambda function

        note over F:Terminate client<br/>listen loop

        deactivate F

    end

    T->>S:Stop server

    S->>S:Stop HTTP server

    S-->> T: 

    deactivate S

    note over T:Assert

Examples

You can find examples of how to factor your Lambda function and how to test it:

  1. In the samples;
  2. In the unit tests for this project;
  3. How I use the library in the tests for my own Alexa skill.

Advanced Usage

AWS Mobile SDK with Cognito

If you use either the ClientContext or Identity properties on ILambdaContext in your function, you can specify the serialized JSON for either property as a string when enqueueing a request to the test server to be made available to the function invocation.

An example of providing these values from an xunit test is shown below:

using System;
using System.Threading;
using System.Threading.Tasks;
using MartinCostello.Testing.AwsLambdaTestServer;
using Newtonsoft.Json;
using Xunit;

namespace MyFunctions;

public static class ReverseFunctionWithMobileSdkTests
{
    [Fact]
    public static async Task Function_Reverses_Numbers_With_Mobile_Sdk()
    {
        // Arrange
        using var server = new LambdaTestServer();
        using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(1));

        await server.StartAsync(cancellationTokenSource.Token);

        int[] value = new[] { 1, 2, 3 };
        string json = JsonConvert.SerializeObject(value);
        byte[] content = Encoding.UTF8.GetBytes(json);

        var request = new LambdaTestRequest(content)
        {
            ClientContext = @"{ ""client"": { ""app_title"": ""my-app"" } }",
            CognitoIdentity = @"{ ""identityId"": ""my-identity"" }",
        };

        LambdaTestContext context = await server.EnqueueAsync(json);

        using var httpClient = server.CreateClient();

        // Act
        await ReverseFunction.RunAsync(httpClient, cancellationTokenSource.Token);

        // Assert
        Assert.True(context.Response.TryRead(out LambdaTestResponse response));
        Assert.True(response.IsSuccessful);

        json = await response.ReadAsStringAsync();
        int[] actual = JsonConvert.DeserializeObject<int[]>(json);

        Assert.Equal(new[] { 3, 2, 1 }, actual);
    }
}
Lambda Runtime Options

If your function makes use of the various other properties in the ILambdaContext passed to the function, you can pass an instance of LambdaTestServerOptions to the constructor of LambdaTestServer to change the values the server provides to LambdaBootstrap before it invokes your function.

Options you can specify include the function memory size, timeout and ARN.

The test server does not enforce these values at runtime, unlike the production AWS Lambda environment. They are provided for you to drive the usage of such properties in the code you are testing and should not be relied on to ensure that your function does not take too long to execute or uses too much memory during execution or any other constraints, as appropriate.

An example of this customisation for an xunit test is shown below:

using System;
using System.Threading;
using System.Threading.Tasks;
using MartinCostello.Testing.AwsLambdaTestServer;
using Newtonsoft.Json;
using Xunit;

namespace MyFunctions;

public static class ReverseFunctionWithCustomOptionsTests
{
    [Fact]
    public static async Task Function_Reverses_Numbers_With_Custom_Options()
    {
        // Arrange
        var options = new LambdaTestServerOptions()
        {
            FunctionMemorySize = 256,
            FunctionTimeout = TimeSpan.FromSeconds(30),
            FunctionVersion = 42,
        };

        using var server = new LambdaTestServer(options);
        using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(1));

        await server.StartAsync(cancellationTokenSource.Token);

        int[] value = new[] { 1, 2, 3 };
        string json = JsonConvert.SerializeObject(value);

        LambdaTestContext context = await server.EnqueueAsync(json);

        using var httpClient = server.CreateClient();

        // Act
        await ReverseFunction.RunAsync(httpClient, cancellationTokenSource.Token);

        // Assert
        Assert.True(context.Response.TryRead(out LambdaTestResponse response));
        Assert.True(response.IsSuccessful);

        json = await response.ReadAsStringAsync();
        int[] actual = JsonConvert.DeserializeObject<int[]>(json);

        Assert.Equal(new[] { 3, 2, 1 }, actual);
    }
}
Logging from the Test Server

To help diagnose failing tests, the LambdaTestServer outputs logs of the requests it receives to the emulated AWS Lambda Runtime it provides. To route the logging output to a location of your choosing, you can use the configuration callbacks, such as the constructor overload that accepts an Action<IServiceCollection> or the Configure property on the LambdaTestServerOptions class.

Here's an example of configuring the test server to route its logs to xunit using the xunit-logging library:

using System;
using System.Threading;
using System.Threading.Tasks;
using MartinCostello.Logging.XUnit;
using MartinCostello.Testing.AwsLambdaTestServer;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Xunit;
using Xunit.Abstractions;

namespace MartinCostello.Testing.AwsLambdaTestServer;

public class ReverseFunctionWithLoggingTests : ITestOutputHelperAccessor
{
    public ReverseFunctionWithLoggingTests(ITestOutputHelper outputHelper)
    {
        OutputHelper = outputHelper;
    }

    public ITestOutputHelper OutputHelper { get; set; }

    [Fact]
    public async Task Function_Reverses_Numbers_With_Logging()
    {
        // Arrange
        using var server = new LambdaTestServer(
            (services) => services.AddLogging(
                (builder) => builder.AddXUnit(this)));

        using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(1));

        await server.StartAsync(cancellationTokenSource.Token);

        int[] value = new[] { 1, 2, 3 };
        string json = JsonConvert.SerializeObject(value);

        LambdaTestContext context = await server.EnqueueAsync(json);

        using var httpClient = server.CreateClient();

        // Act
        await ReverseFunction.RunAsync(httpClient, cancellationTokenSource.Token);

        // Assert
        Assert.True(context.Response.TryRead(out LambdaTestResponse response));
        Assert.True(response.IsSuccessful);

        json = await response.ReadAsStringAsync();
        int[] actual = JsonConvert.DeserializeObject<int[]>(json);

        Assert.Equal(new[] { 3, 2, 1 }, actual);
    }
}

This then outputs logs similar to the below into the xunit test results:

Test Name:     Function_Reverses_Numbers_With_Logging
Test Outcome:  Passed
Result StandardOutput:
[2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET http://localhost/2018-06-01/runtime/invocation/next
[2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint '/{LambdaVersion}/runtime/invocation/next HTTP: GET'
[2019-11-04 15:21:06Z] info: MartinCostello.Testing.AwsLambdaTestServer.RuntimeHandler[0]
      Waiting for new request for Lambda function with ARN arn:aws:lambda:eu-west-1:123456789012:function:test-function.
[2019-11-04 15:21:06Z] info: MartinCostello.Testing.AwsLambdaTestServer.RuntimeHandler[0]
      Invoking Lambda function with ARN arn:aws:lambda:eu-west-1:123456789012:function:test-function for request Id 7e1a283d-6268-4401-921c-0d0d67da1da4 and trace Id 51792f7f-2c1e-4934-bfd9-f5f7c6f0d628.
[2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint '/{LambdaVersion}/runtime/invocation/next HTTP: GET'
[2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished in 71.9334ms 200 application/json
[2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 POST http://localhost/2018-06-01/runtime/invocation/7e1a283d-6268-4401-921c-0d0d67da1da4/response application/json
[2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint '/{LambdaVersion}/runtime/invocation/{AwsRequestId}/response HTTP: POST'
[2019-11-04 15:21:06Z] info: MartinCostello.Testing.AwsLambdaTestServer.RuntimeHandler[0]
      Invoked Lambda function with ARN arn:aws:lambda:eu-west-1:123456789012:function:test-function for request Id 7e1a283d-6268-4401-921c-0d0d67da1da4: [3,2,1].
[2019-11-04 15:21:06Z] info: MartinCostello.Testing.AwsLambdaTestServer.RuntimeHandler[0]
      Completed processing AWS request Id 7e1a283d-6268-4401-921c-0d0d67da1da4 for Lambda function with ARN arn:aws:lambda:eu-west-1:123456789012:function:test-function in 107 milliseconds.
[2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint '/{LambdaVersion}/runtime/invocation/{AwsRequestId}/response HTTP: POST'
[2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished in 26.6306ms 204
Custom Lambda Server

It is also possible to use LambdaTestServer with a custom IServer implementation by overriding the CreateServer() method in a derived class.

This can be used, for example, to host the Lambda test server in a real HTTP server that can be accessed remotely instead of being hosted in-memory with the TestServer class.

For examples of this use case, see the MinimalApi example project and its test project in the samples.

Feedback

Any feedback or issues can be added to the issues for this project in GitHub.

Repository

The repository is hosted in GitHub: https://github.com/martincostello/lambda-test-server.git

License

This project is licensed under the Apache 2.0 license.

Building and Testing

Compiling the library yourself requires Git and the .NET SDK to be installed.

To build and test the library locally from a terminal/command-line, run one of the following set of commands:

git clone https://github.com/martincostello/lambda-test-server.git
cd lambda-test-server
./build.ps1
Product Compatible and additional computed target framework versions.
.NET net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 is compatible.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
0.8.0 7,769 11/14/2023
0.7.1 3,947 7/8/2023
0.7.0 18,399 11/8/2022
0.6.0 10,783 3/5/2022
0.5.0 8,752 11/8/2021
0.4.1 2,650 7/12/2021
0.4.0 7,920 11/10/2020
0.3.0 20,785 11/9/2019
0.2.1 475 11/6/2019
0.2.0 493 11/5/2019
0.1.0 561 11/2/2019