From 31cd95ef75e66911bb3aadd4154633972fa5b5d4 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 15 Dec 2025 15:56:18 -0500 Subject: [PATCH 1/3] feat(testing): add customizable JSON serializer options to LambdaServerOptions - Introduced `SerializerOptions` property in `LambdaServerOptions` for configurable JSON serialization - Updated `LambdaTestServer` to utilize `SerializerOptions` from `serverOptions` --- src/MinimalLambda.Testing/LambdaTestServer.cs | 3 +-- .../Options/LambdaServerOptions.cs | 10 ++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 407622d5..3553ff64 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using MinimalLambda.Options; namespace MinimalLambda.Testing; @@ -94,7 +93,7 @@ internal LambdaTestServer( _shutdownCts = CancellationTokenSource.CreateLinkedTokenSource(shutdownToken); State = ServerState.Created; - _jsonSerializerOptions = DefaultLambdaJsonSerializerOptions.Create(); + _jsonSerializerOptions = serverOptions.SerializerOptions; _serverOptions = serverOptions; } diff --git a/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs b/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs index b2404af4..1af1a7c7 100644 --- a/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs +++ b/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs @@ -1,3 +1,6 @@ +using System.Text.Json; +using MinimalLambda.Options; + namespace MinimalLambda.Testing; /// Configuration options for the Lambda test client. @@ -24,4 +27,11 @@ public class LambdaServerOptions /// timeout. /// public TimeSpan FunctionTimeout { get; set; } = TimeSpan.FromSeconds(3); + + /// + /// Gets or sets the JSON serializer options used for Lambda event and response serialization. + /// Defaults to the standard Lambda JSON configuration settings. + /// + public JsonSerializerOptions SerializerOptions { get; set; } = + DefaultLambdaJsonSerializerOptions.Create(); } From b38c9ed32cacb850223b2b476d8517b5ee202438 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 15 Dec 2025 16:02:14 -0500 Subject: [PATCH 2/3] fix(testing): adjust XML documentation and resolve missing namespace in LambdaTestServer - Fixed inconsistent XML documentation formatting in `LambdaServerOptions`. - Added missing `MinimalLambda.Options` namespace in `LambdaTestServer`. --- src/MinimalLambda.Testing/Options/LambdaServerOptions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs b/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs index 1af1a7c7..e9f00a8d 100644 --- a/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs +++ b/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs @@ -29,8 +29,8 @@ public class LambdaServerOptions public TimeSpan FunctionTimeout { get; set; } = TimeSpan.FromSeconds(3); /// - /// Gets or sets the JSON serializer options used for Lambda event and response serialization. - /// Defaults to the standard Lambda JSON configuration settings. + /// Gets or sets the JSON serializer options used for Lambda event and response + /// serialization. Defaults to the standard Lambda JSON configuration settings. /// public JsonSerializerOptions SerializerOptions { get; set; } = DefaultLambdaJsonSerializerOptions.Create(); From b823b5768b9fa98ccca82d387bbd0790d3f584d8 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 15 Dec 2025 20:38:35 -0500 Subject: [PATCH 3/3] feat(docs): enhance testing documentation with advanced examples and reusable patterns - Added detailed examples for reusable factories, shared `IClassFixture`, and mocking dependencies. - Updated invocation examples to use strongly-typed records (`MyEvent`, `MyResponse`) for clarity. - Documented `LambdaServerOptions` features like JSON serialization and runtime headers. - Included guidance for customizing DI containers and reusable test configurations. - Updated testing guide to use `linenums` for syntax-highlighted examples. - Incremented version to `2.0.0-beta.9` in `Directory.Build.props`. --- Directory.Build.props | 2 +- docs/guides/testing.md | 242 +++++++++++++++++++++++++++-------------- 2 files changed, 161 insertions(+), 83 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index f997959f..a3f9256e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.0.0-beta.8 + 2.0.0-beta.9 MIT diff --git a/docs/guides/testing.md b/docs/guides/testing.md index afe88857..6b0698a4 100644 --- a/docs/guides/testing.md +++ b/docs/guides/testing.md @@ -33,6 +33,9 @@ Write an end-to-end test with xUnit v3: using MinimalLambda.Testing; using Xunit; +public record MyEvent(string Name); +public record MyResponse(string Message); + public class HelloWorldTests { [Fact] @@ -45,13 +48,13 @@ public class HelloWorldTests var initResult = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); Assert.Equal(InitStatus.InitCompleted, initResult.InitStatus); - var response = await factory.TestServer.InvokeAsync( - "World", + var response = await factory.TestServer.InvokeAsync( + new MyEvent("World"), TestContext.Current.CancellationToken ); Assert.True(response.WasSuccess); - Assert.Equal("Hello World!", response.Response); + Assert.Equal("Hello World!", response.Response.Message); } } ``` @@ -72,9 +75,9 @@ public class HelloWorldTests Pass a custom `traceId` to control the `Lambda-Runtime-Trace-Id` header for correlation in logs and telemetry: -```csharp -var response = await factory.TestServer.InvokeAsync( - new Request(), +```csharp linenums="1" +var response = await factory.TestServer.InvokeAsync( + new MyEvent("test"), TestContext.Current.CancellationToken, traceId: "custom-trace-id-12345" ); @@ -128,11 +131,137 @@ Add `LambdaApplicationFactoryContentRootAttribute` to your test assembly when yo content root (e.g., static files, JSON fixtures). The factory will pick it up and set the content root before booting the host. +### Shared Fixtures with IClassFixture + +Use xUnit's `IClassFixture` to share a single `LambdaApplicationFactory` across all tests in a class. +This improves test performance by reusing the same Lambda host instance: + +```csharp linenums="1" +public class SimpleLambdaTests(LambdaApplicationFactory factory) + : IClassFixture> +{ + private readonly LambdaTestServer _server = factory.TestServer; + + [Fact] + public async Task SimpleLambda_ReturnsExpectedValue() + { + var response = await _server.InvokeAsync( + new MyEvent("World"), + TestContext.Current.CancellationToken + ); + + response.WasSuccess.Should().BeTrue(); + response.Response.Message.Should().Be("Hello World!"); + } + + [Fact] + public async Task SimpleLambda_WithDifferentInput_ReturnsExpectedValue() + { + var response = await _server.InvokeAsync( + new MyEvent("Lambda"), + TestContext.Current.CancellationToken + ); + + response.WasSuccess.Should().BeTrue(); + response.Response.Message.Should().Be("Hello Lambda!"); + } +} +``` + +**Important:** The same factory instance is used for all tests in the class. This means: + +- OnInit runs once when the first test executes +- OnShutdown runs once when all tests complete +- Singleton services are shared across all tests in the class +- Do not use this pattern if you need to test initialization/shutdown behavior (use a fresh factory + per test instead) + +#### Custom Factory for Reusable Configuration + +For more complex scenarios, extend `LambdaApplicationFactory` to create a reusable fixture +with pre-configured test doubles and settings: + +=== "Custom Factory" + + ```csharp linenums="1" + public class CustomLambdaApplicationFactory : LambdaApplicationFactory + where TProgram : class + { + // Expose test doubles as properties for easy access in tests + public ILifecycleService LifecycleService { get; } = Substitute.For(); + + protected override void ConfigureWebHost(IHostBuilder builder) + { + builder.ConfigureServices((_, services) => + { + // Replace real implementations with test doubles + services.RemoveAll(); + services.AddSingleton(_ => LifecycleService); + }); + + builder.UseEnvironment("Development"); + } + } + ``` + +=== "Test Class" + + ```csharp linenums="1" + public class MyLambdaTests(CustomLambdaApplicationFactory factory) + : IClassFixture> + { + private readonly LambdaTestServer _server = factory.TestServer; + + [Fact] + public async Task Lambda_WithMockedDependency_ReturnsExpectedValue() + { + // Configure the test double for this specific test + factory.LifecycleService.OnStart().Returns(true); + + var response = await _server.InvokeAsync( + new MyEvent("World"), + TestContext.Current.CancellationToken + ); + + response.WasSuccess.Should().BeTrue(); + response.Response.Message.Should().Be("Hello World!"); + } + } + ``` + +This pattern is useful when: + +- Multiple test classes need the same test setup +- You want to expose test doubles as properties for easy configuration +- You need consistent environment settings across many tests + ### Tuning the Runtime Shim -`LambdaServerOptions` controls the simulated runtime headers and timing (ARN, deadline/timeout, -extra headers). Access it via `factory.ServerOptions` before starting the server if you need -test-specific values. +`LambdaServerOptions` controls the simulated runtime headers, timing, and serialization behavior. +Access it via `factory.ServerOptions` before starting the server if you need test-specific values: + +- **Runtime headers** – `FunctionArn`, `AdditionalHeaders` for custom Lambda runtime headers +- **Timeout behavior** – `FunctionTimeout` controls invocation deadline (defaults to 3 seconds) +- **JSON serialization** – `SerializerOptions` controls how the test server serializes events and + responses sent to your handler + +```csharp linenums="1" +await using var factory = new LambdaApplicationFactory(); + +// Configure test server options before starting +factory.ServerOptions.FunctionTimeout = TimeSpan.FromSeconds(10); +factory.ServerOptions.FunctionArn = "arn:aws:lambda:us-east-1:123456789012:function:MyFunc"; +factory.ServerOptions.SerializerOptions = new JsonSerializerOptions +{ + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + WriteIndented = true +}; + +var response = await factory.TestServer.InvokeAsync( + new MyEvent("test"), + TestContext.Current.CancellationToken +); +``` ## Initialization and Shutdown Behavior @@ -164,15 +293,15 @@ test-specific values. Validate error handling by asserting on `InvocationResponse.Error`: -```csharp +```csharp linenums="1" [Fact] public async Task Handler_WithInvalidInput_ReturnsStructuredError() { await using var factory = new LambdaApplicationFactory() .WithCancellationToken(TestContext.Current.CancellationToken); - var response = await factory.TestServer.InvokeAsync( - "", // Invalid input + var response = await factory.TestServer.InvokeAsync( + new MyEvent(""), // Invalid: empty name TestContext.Current.CancellationToken ); @@ -187,7 +316,7 @@ public async Task Handler_WithInvalidInput_ReturnsStructuredError() The test server handles concurrent invocations safely with FIFO ordering: -```csharp +```csharp linenums="1" [Fact] public async Task ConcurrentInvocations_AreHandledInOrder() { @@ -196,8 +325,8 @@ public async Task ConcurrentInvocations_AreHandledInOrder() // Launch multiple concurrent invocations var tasks = Enumerable.Range(1, 10) - .Select(i => factory.TestServer.InvokeAsync( - i, + .Select(i => factory.TestServer.InvokeAsync( + new MyEvent($"User{i}"), TestContext.Current.CancellationToken)) .ToArray(); @@ -207,9 +336,9 @@ public async Task ConcurrentInvocations_AreHandledInOrder() responses.Should().AllSatisfy(r => r.WasSuccess.Should().BeTrue()); // Responses maintain FIFO order - responses.Select(r => r.Response) - .Should().ContainInOrder("Hello 1!", "Hello 2!", "Hello 3!", "Hello 4!", "Hello 5!", - "Hello 6!", "Hello 7!", "Hello 8!", "Hello 9!", "Hello 10!"); + responses.Select(r => r.Response.Message) + .Should().ContainInOrder("Hello User1!", "Hello User2!", "Hello User3!", "Hello User4!", "Hello User5!", + "Hello User6!", "Hello User7!", "Hello User8!", "Hello User9!", "Hello User10!"); } ``` @@ -217,15 +346,15 @@ public async Task ConcurrentInvocations_AreHandledInOrder() Verify middleware behavior by inspecting response metadata or side effects: -```csharp +```csharp linenums="1" [Fact] public async Task CustomMiddleware_AddsExpectedHeaders() { await using var factory = new LambdaApplicationFactory() .WithCancellationToken(TestContext.Current.CancellationToken); - var response = await factory.TestServer.InvokeAsync( - new Request(), + var response = await factory.TestServer.InvokeAsync( + new MyEvent("test"), TestContext.Current.CancellationToken ); @@ -240,7 +369,7 @@ public async Task CustomMiddleware_AddsExpectedHeaders() #### OnInit That Signals Shutdown -```csharp +```csharp linenums="1" [Fact] public async Task OnInit_WhenReturningFalse_ShutsDownGracefully() { @@ -252,7 +381,7 @@ public async Task OnInit_WhenReturningFalse_ShutsDownGracefully() builder.ConfigureServices((_, services) => { services.RemoveAll(); - services.AddSingleton(mockService); + services.AddSingleton(mockService); })); var initResult = await factory.TestServer.StartAsync( @@ -265,7 +394,7 @@ public async Task OnInit_WhenReturningFalse_ShutsDownGracefully() #### OnInit That Throws Exceptions -```csharp +```csharp linenums="1" [Fact] public async Task OnInit_WhenThrowingException_ReturnsInitError() { @@ -277,7 +406,7 @@ public async Task OnInit_WhenThrowingException_ReturnsInitError() builder.ConfigureServices((_, services) => { services.RemoveAll(); - services.AddSingleton(mockService); + services.AddSingleton(mockService); })); var initResult = await factory.TestServer.StartAsync( @@ -291,7 +420,7 @@ public async Task OnInit_WhenThrowingException_ReturnsInitError() #### OnShutdown Exception Handling -```csharp +```csharp linenums="1" [Fact] public async Task OnShutdown_WhenThrowingException_AggregatesExceptions() { @@ -303,7 +432,7 @@ public async Task OnShutdown_WhenThrowingException_AggregatesExceptions() builder.ConfigureServices((_, services) => { services.RemoveAll(); - services.AddSingleton(mockService); + services.AddSingleton(mockService); })); await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); @@ -318,35 +447,11 @@ public async Task OnShutdown_WhenThrowingException_AggregatesExceptions() } ``` -### Inspecting Services After Invocation - -Resolve singleton services from `factory.TestServer.Services` to validate state: - -```csharp -[Fact] -public async Task AfterInvocation_CanInspectSingletonState() -{ - await using var factory = new LambdaApplicationFactory() - .WithCancellationToken(TestContext.Current.CancellationToken); - - await factory.TestServer.InvokeAsync( - new Request("test"), - TestContext.Current.CancellationToken - ); - - // Inspect singleton services after invocation - var metricsCollector = factory.TestServer.Services - .GetRequiredService(); - - metricsCollector.InvocationCount.Should().Be(1); -} -``` - ### Alternative DI Containers Replace the default DI container with Autofac, DryIoc, or other containers: -```csharp +```csharp linenums="1" [Fact] public async Task WithAutofac_CustomContainerWorks() { @@ -359,35 +464,8 @@ public async Task WithAutofac_CustomContainerWorks() containerBuilder.RegisterType().As(); })); - var response = await factory.TestServer.InvokeAsync( - new Request(), - TestContext.Current.CancellationToken - ); - - response.WasSuccess.Should().BeTrue(); -} -``` - -### Custom JSON Serialization - -Override JSON serialization options to match your Lambda's configuration: - -```csharp -[Fact] -public async Task CustomJsonOptions_AreRespected() -{ - await using var factory = new LambdaApplicationFactory() - .WithHostBuilder(builder => - builder.ConfigureServices((_, services) => - { - services.Configure(options => - { - options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; - }); - })); - - var response = await factory.TestServer.InvokeAsync( - new Request { UserName = "test" }, + var response = await factory.TestServer.InvokeAsync( + new MyEvent("test"), TestContext.Current.CancellationToken ); @@ -399,7 +477,7 @@ public async Task CustomJsonOptions_AreRespected() Measure initialization and invocation performance: -```csharp +```csharp linenums="1" [Fact] public async Task ColdStart_InitCompletesWithinTimeout() {