Integration-Test Lambda Handlers End-to-End Without Deploying
Build a full in-memory test suite for a multi-event-source Lambda function — API Gateway and SQS —
using the same StartUp class you deploy, so a passing test suite means the real pipeline works.
Problem Statement
You've written a Lambda function with BenzeneStartUp that handles both an API Gateway endpoint
and an SQS queue. Before deploying, you want to:
- Exercise both event sources end to end (request → routing → handler → response) without deploying or running SAM local
- Assert on the actual response shape API Gateway callers see (status code, body) and on the actual SQS partial-batch-failure behavior
- Assert on side effects — that a downstream dependency was actually called with the right arguments — using mocks, without spinning up real infrastructure
- Avoid the DI setup mistakes that produce a working build but a runtime
BenzeneExceptionthe first time a message actually flows through the pipeline
This cookbook assumes you've read Testing Benzene (the reference doc for
BenzeneTestHost) and the testing section of
Getting Started: Benzene on AWS Lambda.
Both are accurate ground truth for the BenzeneTestHost.Create<TStartUp>().BuildAwsLambdaHost()
API; this cookbook goes one level deeper with a complete, realistic multi-handler test suite built
on top of it, plus a troubleshooting section for setup mistakes that don't show up until runtime.
Prerequisites
- A Lambda function project using
BenzeneStartUpwith at least one API Gateway and one SQS handler wired up (see Getting Started: Benzene on AWS Lambda). - A test project referencing the function project.
- xUnit and Moq (the test conventions used throughout Benzene's own
test/folder).
Installation
dotnet add package Benzene.Testing --prerelease
dotnet add package Benzene.Aws.Lambda.Core.TestHelpers --prerelease
dotnet add package Benzene.Aws.Lambda.ApiGateway.TestHelpers --prerelease
dotnet add package Benzene.Aws.Lambda.Sqs.TestHelpers --prerelease
dotnet add package xunit
dotnet add package Moq
Benzene.Testing provides BenzeneTestHost/HttpBuilder/MessageBuilder. Benzene.Aws.Lambda.Core.TestHelpers
provides AwsLambdaBenzeneTestHost (and the BuildAwsLambdaHost() extension), the wrapper that turns the built IAwsLambdaEntryPoint into
something you can send events into and get typed responses back from. The two TestHelpers
packages add the SendApiGatewayAsync/SendSqsAsync extensions and the
AsApiGatewayRequest/AsSqs builder conversions for their respective event sources.
The App Under Test
A small order-processing function: POST /orders charges a customer through a payment gateway
port and returns the created order; an orders:shipped SQS message notifies the customer's
shipping notifier port. Both handlers depend on interfaces (ports) that are easy to mock in tests.
// Handlers.cs
using Benzene.Abstractions.MessageHandlers;
using Benzene.Abstractions.Results;
using Benzene.Core.MessageHandlers;
using Benzene.Http;
using Benzene.Results;
using Void = Benzene.Abstractions.Results.Void; // disambiguate from System.Void under implicit usings
public interface IPaymentGateway
{
Task<IBenzeneResult<string>> ChargeAsync(string customerId, int amountInCents);
}
public interface IShippingNotifier
{
Task NotifyCustomerAsync(Guid orderId, string trackingNumber);
}
public class CreateOrderRequest
{
public string CustomerId { get; set; } = "";
public int AmountInCents { get; set; }
}
public class CreateOrderResponse
{
public Guid OrderId { get; set; } = default!;
public string ChargeId { get; set; } = "";
}
[Message("orders:create")]
[HttpEndpoint("POST", "/orders")]
public class CreateOrderMessageHandler : IMessageHandler<CreateOrderRequest, CreateOrderResponse>
{
private readonly IPaymentGateway _paymentGateway;
public CreateOrderMessageHandler(IPaymentGateway paymentGateway)
{
_paymentGateway = paymentGateway;
}
public async Task<IBenzeneResult<CreateOrderResponse>> HandleAsync(CreateOrderRequest message)
{
var charge = await _paymentGateway.ChargeAsync(message.CustomerId, message.AmountInCents);
if (!charge.IsSuccessful)
{
return BenzeneResult.BadRequest<CreateOrderResponse>("Payment declined");
}
return BenzeneResult.Created(new CreateOrderResponse
{
OrderId = Guid.NewGuid(),
ChargeId = charge.Payload
});
}
}
public class OrderShippedEvent
{
public Guid OrderId { get; set; }
public string TrackingNumber { get; set; } = "";
}
[Message("orders:shipped")]
public class OrderShippedMessageHandler : IMessageHandler<OrderShippedEvent, Void>
{
private readonly IShippingNotifier _notifier;
public OrderShippedMessageHandler(IShippingNotifier notifier)
{
_notifier = notifier;
}
public async Task<IBenzeneResult<Void>> HandleAsync(OrderShippedEvent message)
{
await _notifier.NotifyCustomerAsync(message.OrderId, message.TrackingNumber);
return BenzeneResult.Ok(new Void());
}
}
// StartUp.cs
using Benzene.Abstractions.Hosting;
using Benzene.Aws.Lambda.ApiGateway;
using Benzene.Aws.Lambda.Core;
using Benzene.Aws.Lambda.Sqs;
using Benzene.Core.MessageHandlers;
using Benzene.Core.MessageHandlers.DI;
using Benzene.Http;
using Benzene.Microsoft.Dependencies;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
public class StartUp : BenzeneStartUp
{
public override IConfiguration GetConfiguration() =>
new ConfigurationBuilder().AddEnvironmentVariables().Build();
public override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.UsingBenzene(x => x
// Required because the SQS handler below resolves ISetCurrentTransport directly
// (SqsApplication.HandleAsync) — see Troubleshooting if you forget this.
.AddBenzene()
.AddMessageHandlers(typeof(CreateOrderMessageHandler).Assembly)
.AddHttpMessageHandlers());
services.AddScoped<IPaymentGateway, StripePaymentGateway>();
services.AddScoped<IShippingNotifier, EmailShippingNotifier>();
}
public override void Configure(IBenzeneApplicationBuilder app, IConfiguration configuration)
{
app.UseAwsLambda(eventPipeline => eventPipeline
.UseApiGateway(apiGatewayApp => apiGatewayApp
.UseMessageHandlers())
.UseSqs(sqsApp => sqsApp
.UseMessageHandlers()));
}
}
UseApiGateway(...) and UseSqs(...) both hang off the same eventPipeline — a single Lambda
function handling multiple event sources, exactly as described in
Getting Started: Supported Event Sources.
StartUp wires the two ports to their production adapters. Their bodies aren't the point of this
cookbook — the tests below replace both with mocks — so they're stubbed here just enough to make the
function a complete, buildable project:
// Adapters.cs
using Benzene.Abstractions.Results;
public class StripePaymentGateway : IPaymentGateway
{
public Task<IBenzeneResult<string>> ChargeAsync(string customerId, int amountInCents) =>
throw new NotImplementedException("Calls the Stripe SDK in production.");
}
public class EmailShippingNotifier : IShippingNotifier
{
public Task NotifyCustomerAsync(Guid orderId, string trackingNumber) =>
throw new NotImplementedException("Calls your email provider in production.");
}
The Test Suite
// OrderFunctionTests.cs
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.SQSEvents;
using Benzene.Aws.Lambda.ApiGateway.TestHelpers;
using Benzene.Aws.Lambda.Sqs.TestHelpers;
using Benzene.Results;
using Benzene.Testing;
using Benzene.Aws.Lambda.Core.TestHelpers;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Xunit;
public class OrderFunctionTests
{
private static AwsLambdaBenzeneTestHost BuildHost(
Mock<IPaymentGateway> paymentGateway,
Mock<IShippingNotifier> notifier)
{
return new AwsLambdaBenzeneTestHost(
BenzeneTestHost.Create<StartUp>()
.WithServices(services =>
{
services.AddScoped(_ => paymentGateway.Object);
services.AddScoped(_ => notifier.Object);
})
.BuildAwsLambdaHost());
}
[Fact]
public async Task CreateOrder_ChargeSucceeds_Returns201WithOrder()
{
var paymentGateway = new Mock<IPaymentGateway>();
paymentGateway
.Setup(x => x.ChargeAsync("customer-1", 2500))
.ReturnsAsync(BenzeneResult.Ok("charge-abc"));
using var host = BuildHost(paymentGateway, new Mock<IShippingNotifier>());
var request = HttpBuilder
.Create("POST", "/orders", new CreateOrderRequest { CustomerId = "customer-1", AmountInCents = 2500 });
var response = await host.SendApiGatewayAsync(request);
Assert.Equal(201, response.StatusCode);
var body = AwsLambdaBenzeneTestHost.StreamToObject<CreateOrderResponse>(
new MemoryStream(System.Text.Encoding.UTF8.GetBytes(response.Body)));
Assert.Equal("charge-abc", body.ChargeId);
paymentGateway.Verify(x => x.ChargeAsync("customer-1", 2500), Times.Once());
}
[Fact]
public async Task CreateOrder_ChargeDeclined_Returns400()
{
var paymentGateway = new Mock<IPaymentGateway>();
paymentGateway
.Setup(x => x.ChargeAsync(It.IsAny<string>(), It.IsAny<int>()))
.ReturnsAsync(BenzeneResult.BadRequest<string>("card_declined"));
using var host = BuildHost(paymentGateway, new Mock<IShippingNotifier>());
var request = HttpBuilder
.Create("POST", "/orders", new CreateOrderRequest { CustomerId = "customer-1", AmountInCents = 2500 });
var response = await host.SendApiGatewayAsync(request);
Assert.Equal(400, response.StatusCode);
}
[Fact]
public async Task OrderShipped_NotifiesCustomer_NoBatchFailures()
{
var notifier = new Mock<IShippingNotifier>();
var orderId = Guid.NewGuid();
using var host = BuildHost(new Mock<IPaymentGateway>(), notifier);
var message = MessageBuilder.Create("orders:shipped", new OrderShippedEvent
{
OrderId = orderId,
TrackingNumber = "1Z999AA10123456784"
});
SQSBatchResponse response = await host.SendSqsAsync(message);
Assert.Empty(response.BatchItemFailures);
notifier.Verify(x => x.NotifyCustomerAsync(orderId, "1Z999AA10123456784"), Times.Once());
}
[Fact]
public async Task OrderShipped_NotifierThrows_ReportsPartialBatchFailure()
{
var notifier = new Mock<IShippingNotifier>();
notifier
.Setup(x => x.NotifyCustomerAsync(It.IsAny<Guid>(), It.IsAny<string>()))
.ThrowsAsync(new InvalidOperationException("email provider down"));
using var host = BuildHost(new Mock<IPaymentGateway>(), notifier);
var message = MessageBuilder.Create("orders:shipped", new OrderShippedEvent
{
OrderId = Guid.NewGuid(),
TrackingNumber = "1Z999AA10123456784"
});
SQSBatchResponse response = await host.SendSqsAsync(message);
// SqsApplication reports the failing record so SQS retries/DLQs just that message,
// not the whole batch — see handling-sqs-failures.md.
Assert.Single(response.BatchItemFailures);
}
}
A few things worth calling out:
BuildHost(...)is a small private factory that centralizes theWithServicesoverrides — every test gets its own pair of mocks, so assertions on one test's mock can't leak into another.AwsLambdaBenzeneTestHostimplementsIDisposable(it disposes the underlyingIAwsLambdaEntryPoint); theusing var host = ...pattern above releases it after each test.response.BodyonAPIGatewayProxyResponseis a plain JSON string — deserialize it with whatever the rest of your test project already uses (System.Text.Json,Newtonsoft.Json, orAwsLambdaBenzeneTestHost.StreamToObject<T>wrapped around aMemoryStream, as above).- The SQS test asserts on
response.BatchItemFailures, not a return value from the handler — that list is exactly whatSqsLambdaHandler/SqsApplicationreports back to the real Lambda service for partial-batch retry, so asserting on it here is asserting on real AWS-facing behavior, not an implementation detail. See Handling SQS Message Failures for the retry/DLQ picture this feeds into.
Troubleshooting
BenzeneException: Unable to resolve type ...ISetCurrentTransport, ...
This is the most common first-run failure for a function that wires up SQS, SNS, or Kafka (or the
BenzeneMessage bridge via UseBenzeneMessage(...)). Those transports call
serviceResolver.GetService<ISetCurrentTransport>() directly while dispatching each
message/record — unlike UseApiGateway(...), which doesn't need it — and ISetCurrentTransport is
only registered by .AddBenzene():
services.UsingBenzene(x => x
.AddBenzene() // <-- registers ISetCurrentTransport (and ICurrentTransport)
.AddMessageHandlers(typeof(CreateOrderMessageHandler).Assembly)
.AddHttpMessageHandlers());
If your Configure also bridges through UseBenzeneMessage(...) (rather than UseSqs/UseSns
directly), you additionally need .AddBenzeneMessage():
services.UsingBenzene(x => x
.AddBenzene()
.AddBenzeneMessage()
.AddMessageHandlers(typeof(CreateOrderMessageHandler).Assembly));
Because BuildAwsLambdaHost() performs the exact same construction as a real deployment (see
Getting Started: Troubleshooting), this mistake
surfaces identically in a test and in production — the test failing here is exactly the point: it
catches the misconfiguration before a real SQS message ever hits the deployed function.
WithServices mock never gets called / real dependency runs instead
WithServices(...) actions run immediately after StartUp.ConfigureServices (see
Testing Benzene: Override Services), so they only
override a registration your StartUp actually makes. If ConfigureServices doesn't register
IPaymentGateway at all (e.g. it's resolved via a concrete class with no interface registration),
there's nothing for the override to replace. Add the interface registration in StartUp first,
then override it in the test.
404 / handler never invoked from SendApiGatewayAsync
Same causes as a real deployment (see
Getting Started: Troubleshooting): the HttpBuilder
method/path must match [HttpEndpoint("METHOD", "/path")] exactly, and the handler's assembly must
be passed to AddMessageHandlers(...) (or you used the no-argument overload, which only scans the
calling assembly — pass the handler assembly explicitly if handlers live in a different project
than StartUp).
SQS test always reports a batch failure, even with a passing handler
Check that MessageBuilder.Create(topic, message) uses the exact topic your [Message("...")]
attribute declares — AsSqs() puts Topic into a topic message attribute, and
SqsMessageTopicGetter routes purely off that attribute, not the message body. A topic typo means
the router finds no matching handler, which the pipeline reports as a failed record, not a
thrown exception.
Assertions on a mock fail even though the test "looks" async-correct
BuildAwsLambdaHost() and SendApiGatewayAsync/SendSqsAsync are async all the way down —
missing an await on host.Send...Async(...) means your assertions run before the handler (and
therefore the mocked dependency) has actually been invoked. This is a plain C# async/await
mistake, not Benzene-specific, but it's an easy one to make when a test method forgets to declare
itself async Task.
Test behaves differently from the deployed function
BuildAwsLambdaHost() performs the exact same GetConfiguration()/ConfigureServices()/
Configure() construction AwsLambdaHost<TStartUp> does for a real deployment — a divergence
almost always means a WithServices/WithConfiguration override in the test is masking something
real (e.g. overriding a repository that would otherwise fail to connect). Temporarily remove the
override to confirm, per
Getting Started: Troubleshooting.
Variations
Asserting on invocation identity
If your pipeline adds .UseBenzeneInvocation() on the outer eventPipeline (see
Getting Started: Observability), you can inject
IBenzeneInvocation into a handler and assert on InvocationId the same way as any other
constructor dependency — it's populated per-invocation for a single-request pipeline like API
Gateway, but not inside SQS/SNS/Kafka's per-message batch dispatch.
A topic-routed test without a specific transport
If your Configure wires up UseBenzeneMessage(...), you can send a plain MessageBuilder
straight at the pipeline via SendBenzeneMessageAsync (from
Benzene.Core.MessageHandlers.TestHelpers) instead of going through a specific transport's event
shape — see Testing Benzene: AWS Lambda.
Further Reading
- Testing Benzene — the full
BenzeneTestHostreference, including Azure Functions, ASP.NET Core, and Worker hosts - Getting Started: Benzene on AWS Lambda — the platform-neutral
BenzeneStartUpshape this cookbook'sStartUpfollows - Handling SQS Message Failures — the retry/DLQ behavior behind the
BatchItemFailuresassertions above - Message Handlers —
IBenzeneResult<T>/BenzeneResultStatusused in the handlers under test