diff --git a/.csharpierignore b/.csharpierignore
new file mode 100644
index 00000000..37415740
--- /dev/null
+++ b/.csharpierignore
@@ -0,0 +1 @@
+src/AwsLambda.Host.Testing/HostFactoryResolver.cs
\ No newline at end of file
diff --git a/.editorconfig b/.editorconfig
index b3a8c7ed..1693a54e 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -13,4 +13,8 @@ insert_final_newline = true
 trim_trailing_whitespace = true
 
 [*.xml]
-indent_size = 4
\ No newline at end of file
+indent_size = 4
+
+[src/AwsLambda.Host.Testing/HostFactoryResolver.cs]
+ij_formatter_enabled = false
+resharper_disable_formatter = true
\ No newline at end of file
diff --git a/.github/workflows/main-build.yaml b/.github/workflows/main-build.yaml
index d3b1fe17..2e35c609 100644
--- a/.github/workflows/main-build.yaml
+++ b/.github/workflows/main-build.yaml
@@ -75,7 +75,7 @@ jobs:
           ~/.sonar/scanner/dotnet-sonarscanner begin \
             /k:"j-d-ha_minimal-lambda" \
             /o:"j-d-ha" \
-            /d:sonar.exclusions="**/bin/**,**/obj/**,tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/**,**/coverage/**,**/.nuget/**,**/packages/**" \
+            /d:sonar.exclusions="**/bin/**,**/obj/**,tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/**,**/coverage/**,**/.nuget/**,**/packages/**,src/MinimalLambda.Testing/HostFactoryResolver.cs" \
             /d:sonar.coverage.exclusions="**/*" \
             /d:sonar.token="$SONAR_TOKEN"
           dotnet build --no-restore --configuration Release /p:TreatWarningsAsErrors=true
diff --git a/.github/workflows/pr-build.yaml b/.github/workflows/pr-build.yaml
index 7188ff70..5e3c503f 100644
--- a/.github/workflows/pr-build.yaml
+++ b/.github/workflows/pr-build.yaml
@@ -99,7 +99,7 @@ jobs:
           ~/.sonar/scanner/dotnet-sonarscanner begin \
             /k:"j-d-ha_minimal-lambda" \
             /o:"j-d-ha" \
-            /d:sonar.exclusions="**/bin/**,**/obj/**,tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/**,**/coverage/**,**/.nuget/**,**/packages/**" \
+            /d:sonar.exclusions="**/bin/**,**/obj/**,tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/**,**/coverage/**,**/.nuget/**,**/packages/**,src/MinimalLambda.Testing/HostFactoryResolver.cs" \
             /d:sonar.coverage.exclusions="**/*" \
             /d:sonar.token="$SONAR_TOKEN"
           
diff --git a/.github/workflows/validate-pr-title.yaml b/.github/workflows/validate-pr-title.yaml
index 38fae0aa..ff364d96 100644
--- a/.github/workflows/validate-pr-title.yaml
+++ b/.github/workflows/validate-pr-title.yaml
@@ -45,6 +45,7 @@ jobs:
             github
             core
             docs
+            testing
           requireScope: false
           subjectPattern: ^[a-z].*
           subjectPatternError: Subject must start with lowercase letter
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 65004283..67464db4 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,9 +12,13 @@
     <PackageVersion Include="Amazon.Lambda.KinesisFirehoseEvents" Version="2.3.0" />
     <PackageVersion Include="Amazon.Lambda.RuntimeSupport" Version="1.14.1" />
     <PackageVersion Include="Amazon.Lambda.Serialization.SystemTextJson" Version="2.4.4" />
+    <PackageVersion Include="Autofac.Extensions.DependencyInjection" Version="10.0.0" />
     <PackageVersion Include="AutoFixture.AutoNSubstitute" Version="4.18.1" />
     <PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
     <PackageVersion Include="BenchmarkDotNet" Version="0.15.6" />
+    <PackageVersion Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.3.0" />
+    <PackageVersion Include="Microsoft.AspNetCore.Routing" Version="2.3.0" />
+    <PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="10.0.0" />
     <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.0" />
     <PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
     <!-- Source Gen Libraries -->
diff --git a/MinimalLambda.sln b/MinimalLambda.sln
index cd134e3f..8413ae0f 100644
--- a/MinimalLambda.sln
+++ b/MinimalLambda.sln
@@ -20,6 +20,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
 		Directory.Packages.props = Directory.Packages.props
 		THIRD-PARTY-LICENSES.txt = THIRD-PARTY-LICENSES.txt
 		mkdocs.yml = mkdocs.yml
+		.editorconfig = .editorconfig
+		.csharpierignore = .csharpierignore
 	EndProjectSection
 EndProject
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
@@ -71,6 +73,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Envelopes.Kaf
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Envelopes.CloudWatchLogs", "src\Envelopes\MinimalLambda.Envelopes.CloudWatchLogs\MinimalLambda.Envelopes.CloudWatchLogs.csproj", "{6D40345B-6CCF-4A6C-8FA2-5BE6C693E3F7}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing", "src\MinimalLambda.Testing\MinimalLambda.Testing.csproj", "{9FA188D7-CF5F-4F87-B292-2AF69994FF12}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing.UnitTests", "tests\MinimalLambda.Testing.UnitTests\Tests\MinimalLambda.Testing.UnitTests\MinimalLambda.Testing.UnitTests.csproj", "{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MinimalLambda.Testing.UnitTests", "MinimalLambda.Testing.UnitTests", "{47DD369E-341A-4E8F-A227-B1B1E04CB9D6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing.UnitTests.SimpleLambda", "tests\MinimalLambda.Testing.UnitTests\Lambdas\MinimalLambda.Testing.UnitTests.SimpleLambda\MinimalLambda.Testing.UnitTests.SimpleLambda.csproj", "{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing.UnitTests.DiLambda", "tests\MinimalLambda.Testing.UnitTests\Lambdas\MinimalLambda.Testing.UnitTests.DiLambda\MinimalLambda.Testing.UnitTests.DiLambda.csproj", "{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing.UnitTests.NoEventLambda", "tests\MinimalLambda.Testing.UnitTests\Lambdas\MinimalLambda.Testing.UnitTests.NoEventLambda\MinimalLambda.Testing.UnitTests.NoEventLambda.csproj", "{7350482B-DFDE-4DCD-A0C5-899D5EA00F74}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing.UnitTests.NoResponseLambda", "tests\MinimalLambda.Testing.UnitTests\Lambdas\MinimalLambda.Testing.UnitTests.NoResponseLambda\MinimalLambda.Testing.UnitTests.NoResponseLambda.csproj", "{381F49C0-297F-4B61-8A82-E9E502E523AD}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Lambdas", "Lambdas", "{D9109C8A-AFA8-49C8-A19C-381500902B4D}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -345,6 +363,78 @@ Global
 		{6D40345B-6CCF-4A6C-8FA2-5BE6C693E3F7}.Release|x64.Build.0 = Release|Any CPU
 		{6D40345B-6CCF-4A6C-8FA2-5BE6C693E3F7}.Release|x86.ActiveCfg = Release|Any CPU
 		{6D40345B-6CCF-4A6C-8FA2-5BE6C693E3F7}.Release|x86.Build.0 = Release|Any CPU
+		{9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Debug|x64.Build.0 = Debug|Any CPU
+		{9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Debug|x86.Build.0 = Debug|Any CPU
+		{9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|Any CPU.Build.0 = Release|Any CPU
+		{9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|x64.ActiveCfg = Release|Any CPU
+		{9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|x64.Build.0 = Release|Any CPU
+		{9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|x86.ActiveCfg = Release|Any CPU
+		{9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|x86.Build.0 = Release|Any CPU
+		{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Debug|x64.Build.0 = Debug|Any CPU
+		{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Debug|x86.Build.0 = Debug|Any CPU
+		{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Release|Any CPU.Build.0 = Release|Any CPU
+		{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Release|x64.ActiveCfg = Release|Any CPU
+		{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Release|x64.Build.0 = Release|Any CPU
+		{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Release|x86.ActiveCfg = Release|Any CPU
+		{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Release|x86.Build.0 = Release|Any CPU
+		{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Debug|x64.Build.0 = Debug|Any CPU
+		{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Debug|x86.Build.0 = Debug|Any CPU
+		{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Release|Any CPU.Build.0 = Release|Any CPU
+		{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Release|x64.ActiveCfg = Release|Any CPU
+		{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Release|x64.Build.0 = Release|Any CPU
+		{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Release|x86.ActiveCfg = Release|Any CPU
+		{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Release|x86.Build.0 = Release|Any CPU
+		{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Debug|x64.Build.0 = Debug|Any CPU
+		{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Debug|x86.Build.0 = Debug|Any CPU
+		{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Release|Any CPU.Build.0 = Release|Any CPU
+		{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Release|x64.ActiveCfg = Release|Any CPU
+		{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Release|x64.Build.0 = Release|Any CPU
+		{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Release|x86.ActiveCfg = Release|Any CPU
+		{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Release|x86.Build.0 = Release|Any CPU
+		{7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Debug|x64.Build.0 = Debug|Any CPU
+		{7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Debug|x86.Build.0 = Debug|Any CPU
+		{7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Release|Any CPU.Build.0 = Release|Any CPU
+		{7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Release|x64.ActiveCfg = Release|Any CPU
+		{7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Release|x64.Build.0 = Release|Any CPU
+		{7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Release|x86.ActiveCfg = Release|Any CPU
+		{7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Release|x86.Build.0 = Release|Any CPU
+		{381F49C0-297F-4B61-8A82-E9E502E523AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{381F49C0-297F-4B61-8A82-E9E502E523AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{381F49C0-297F-4B61-8A82-E9E502E523AD}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{381F49C0-297F-4B61-8A82-E9E502E523AD}.Debug|x64.Build.0 = Debug|Any CPU
+		{381F49C0-297F-4B61-8A82-E9E502E523AD}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{381F49C0-297F-4B61-8A82-E9E502E523AD}.Debug|x86.Build.0 = Debug|Any CPU
+		{381F49C0-297F-4B61-8A82-E9E502E523AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{381F49C0-297F-4B61-8A82-E9E502E523AD}.Release|Any CPU.Build.0 = Release|Any CPU
+		{381F49C0-297F-4B61-8A82-E9E502E523AD}.Release|x64.ActiveCfg = Release|Any CPU
+		{381F49C0-297F-4B61-8A82-E9E502E523AD}.Release|x64.Build.0 = Release|Any CPU
+		{381F49C0-297F-4B61-8A82-E9E502E523AD}.Release|x86.ActiveCfg = Release|Any CPU
+		{381F49C0-297F-4B61-8A82-E9E502E523AD}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -373,5 +463,13 @@ Global
 		{71EB7C3D-6EFE-4189-BE50-DC7C83D666F5} = {1C3C52D9-2936-4A0C-A9C8-F330F22B8359}
 		{DA647025-1B7B-425A-9405-8E015F6DA723} = {1C3C52D9-2936-4A0C-A9C8-F330F22B8359}
 		{6D40345B-6CCF-4A6C-8FA2-5BE6C693E3F7} = {1C3C52D9-2936-4A0C-A9C8-F330F22B8359}
+		{9FA188D7-CF5F-4F87-B292-2AF69994FF12} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+		{47DD369E-341A-4E8F-A227-B1B1E04CB9D6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+		{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF} = {47DD369E-341A-4E8F-A227-B1B1E04CB9D6}
+		{D9109C8A-AFA8-49C8-A19C-381500902B4D} = {47DD369E-341A-4E8F-A227-B1B1E04CB9D6}
+		{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7} = {D9109C8A-AFA8-49C8-A19C-381500902B4D}
+		{7350482B-DFDE-4DCD-A0C5-899D5EA00F74} = {D9109C8A-AFA8-49C8-A19C-381500902B4D}
+		{381F49C0-297F-4B61-8A82-E9E502E523AD} = {D9109C8A-AFA8-49C8-A19C-381500902B4D}
+		{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A} = {D9109C8A-AFA8-49C8-A19C-381500902B4D}
 	EndGlobalSection
 EndGlobal
diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt
index 8373afe4..34bca1ed 100644
--- a/THIRD-PARTY-LICENSES.txt
+++ b/THIRD-PARTY-LICENSES.txt
@@ -306,4 +306,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
+================================================================================
+
+5. runtime
+--------------------------------------------------------------------------------
+Source: https://github.com/dotnet/runtime
+Version: v10.0.0
+License: MIT License
+
+The MIT License (MIT)
+
+Copyright (c) .NET Foundation and Contributors
+
+All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
 ================================================================================
\ No newline at end of file
diff --git a/codecov.yml b/codecov.yml
index 853b288d..c8d7fbc2 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -17,4 +17,6 @@ ignore:
   - "tests/**"
   - "examples/**"
   - "benchmarks/**"
-  - "**/Program.cs"
\ No newline at end of file
+  - "**/Program.cs"
+  - "**/Program.cs"
+  - "src/MinimalLambda.Testing/HostFactoryResolver.cs"
diff --git a/scripts/validate-version.sh b/scripts/validate-version.sh
index 33cf0463..4147cfe8 100644
--- a/scripts/validate-version.sh
+++ b/scripts/validate-version.sh
@@ -12,10 +12,10 @@ cd "$DIR" || exit 1
 PACKAGE_ID=$(grep -o '<PackageId>[^<]*</PackageId>' *.csproj | head -1 | sed 's/<PackageId>\(.*\)<\/PackageId>/\1/')
 
 # Extract version from Directory.Build.props (BSD grep compatible)
-VERSION=$(grep -o '<VersionPrefix>[^<]*</VersionPrefix>' ../../Directory.Build.props | head -1 | sed 's/<VersionPrefix>\(.*\)<\/VersionPrefix>/\1/')
+VERSION=$(grep -o '<Version>[^<]*</Version>' ../../Directory.Build.props | head -1 | sed 's/<Version>\(.*\)<\/Version>/\1/')
 
 if [[ -z "$PACKAGE_ID" ]] || [[ -z "$VERSION" ]]; then
-  echo "Error: Could not extract PackageId or VersionPrefix" >&2
+  echo "Error: Could not extract PackageId or Version" >&2
   exit 1
 fi
 
diff --git a/src/MinimalLambda.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs b/src/MinimalLambda.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs
new file mode 100644
index 00000000..b30c5b8f
--- /dev/null
+++ b/src/MinimalLambda.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs
@@ -0,0 +1,35 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Amazon.Lambda.Serialization.SystemTextJson;
+using Amazon.Lambda.Serialization.SystemTextJson.Converters;
+
+namespace MinimalLambda.Options;
+
+/// <summary>Provides the default JSON serializer options used by AWS Lambda.</summary>
+public static class DefaultLambdaJsonSerializerOptions
+{
+    /// <summary>
+    ///     Creates a <see cref="JsonSerializerOptions" /> instance that matches the defaults used by
+    ///     <see cref="DefaultLambdaJsonSerializer" />.
+    /// </summary>
+    /// <remarks>
+    ///     <para>Configures null-value ignoring, case-insensitive property names, and the AWS naming policy.</para>
+    ///     <para>Adds the AWS-provided converters for dates, memory streams, constant classes, and byte arrays.</para>
+    /// </remarks>
+    /// <returns>Configured JSON serializer options suitable for AWS Lambda payloads.</returns>
+    public static JsonSerializerOptions Create()
+    {
+        var options = new JsonSerializerOptions
+        {
+            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+            PropertyNameCaseInsensitive = true,
+            PropertyNamingPolicy = new AwsNamingPolicy(),
+        };
+        options.Converters.Add(new DateTimeConverter());
+        options.Converters.Add(new MemoryStreamConverter());
+        options.Converters.Add(new ConstantClassConverter());
+        options.Converters.Add(new ByteArrayConverter());
+
+        return options;
+    }
+}
diff --git a/src/MinimalLambda.Abstractions/Options/EnvelopeOptions.cs b/src/MinimalLambda.Abstractions/Options/EnvelopeOptions.cs
index c25fba1a..6d2645d1 100644
--- a/src/MinimalLambda.Abstractions/Options/EnvelopeOptions.cs
+++ b/src/MinimalLambda.Abstractions/Options/EnvelopeOptions.cs
@@ -1,8 +1,6 @@
 using System.Text.Json;
-using System.Text.Json.Serialization;
 using System.Xml;
 using Amazon.Lambda.Serialization.SystemTextJson;
-using Amazon.Lambda.Serialization.SystemTextJson.Converters;
 using MinimalLambda.Envelopes;
 
 namespace MinimalLambda.Options;
@@ -57,28 +55,8 @@ public class EnvelopeOptions
     ///         not been explicitly configured.
     ///     </para>
     /// </remarks>
-    public JsonSerializerOptions LambdaDefaultJsonOptions
-    {
-        get
-        {
-            if (field is null)
-            {
-                field = new JsonSerializerOptions
-                {
-                    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
-                    PropertyNameCaseInsensitive = true,
-                    PropertyNamingPolicy = new AwsNamingPolicy(),
-                };
-                field.Converters.Add(new DateTimeConverter());
-                field.Converters.Add(new MemoryStreamConverter());
-                field.Converters.Add(new ConstantClassConverter());
-                field.Converters.Add(new ByteArrayConverter());
-            }
-
-            return field;
-        }
-        set;
-    }
+    public JsonSerializerOptions LambdaDefaultJsonOptions { get; set; } =
+        DefaultLambdaJsonSerializerOptions.Create();
 
     /// <summary>Gets or sets the XML reader settings used when deserializing Lambda event payloads.</summary>
     /// <remarks>
diff --git a/src/MinimalLambda.Testing/DeferredHostBuilder.cs b/src/MinimalLambda.Testing/DeferredHostBuilder.cs
new file mode 100644
index 00000000..4b26d5aa
--- /dev/null
+++ b/src/MinimalLambda.Testing/DeferredHostBuilder.cs
@@ -0,0 +1,184 @@
+// Portions of this file are derived from aspnetcore
+// Source:
+// https://github.com/dotnet/aspnetcore/blob/v10.0.0/src/Mvc/Mvc.Testing/src/DeferredHostBuilder.cs
+// Copyright (c) .NET Foundation and Contributors
+// Licensed under the MIT License
+// See THIRD-PARTY-LICENSES.txt file in the project root or visit
+// https://github.com/dotnet/aspnetcore/blob/v10.0.0/LICENSE.txt
+
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace MinimalLambda.Testing;
+
+// This host builder captures calls to the IHostBuilder then replays them in the call to
+// ConfigureHostBuilder
+internal sealed class DeferredHostBuilder : IHostBuilder
+{
+    private readonly TaskCompletionSource<Exception?> _entryPointCompletionTcs = new(
+        TaskCreationOptions.RunContinuationsAsynchronously
+    );
+
+    private readonly ConfigurationManager _hostConfiguration = new();
+
+    // This task represents a call to IHost.Start, we create it here preemptively in case the
+    // application
+    // exits due to an exception or because it didn't wait for the shutdown signal
+    private readonly TaskCompletionSource _hostStartTcs = new(
+        TaskCreationOptions.RunContinuationsAsynchronously
+    );
+
+    private Action<IHostBuilder> _configure;
+    private Func<string[], object>? _hostFactory;
+
+    public DeferredHostBuilder() =>
+        _configure = b =>
+        {
+            // Copy the properties from this builder into the builder
+            // that we're going to receive
+            foreach (var pair in Properties)
+                b.Properties[pair.Key] = pair.Value;
+        };
+
+    public Task<Exception?> EntryPointCompletion => _entryPointCompletionTcs.Task;
+
+    public IDictionary<object, object> Properties { get; } = new Dictionary<object, object>();
+
+    public IHost Build()
+    {
+        // Hosting configuration is being provided by args so that
+        // we can impact WebApplicationBuilder based applications.
+        var args = new List<string>();
+
+        // Transform the host configuration into command line arguments
+        foreach (var (key, value) in _hostConfiguration.AsEnumerable())
+            args.Add($"--{key}={value}");
+
+        // This will never be null if the case where Build is being called
+        var host = (IHost)_hostFactory!(args.ToArray());
+
+        var applicationLifetime = host.Services.GetRequiredService<IHostApplicationLifetime>();
+
+        // We can't return the host directly since we need to defer the call to StartAsync
+        return new DeferredHost(host, _hostStartTcs, applicationLifetime);
+    }
+
+    public IHostBuilder ConfigureAppConfiguration(
+        Action<HostBuilderContext, IConfigurationBuilder> configureDelegate
+    )
+    {
+        _configure += b => b.ConfigureAppConfiguration(configureDelegate);
+        return this;
+    }
+
+    public IHostBuilder ConfigureContainer<TContainerBuilder>(
+        Action<HostBuilderContext, TContainerBuilder> configureDelegate
+    )
+    {
+        _configure += b => b.ConfigureContainer(configureDelegate);
+        return this;
+    }
+
+    public IHostBuilder ConfigureHostConfiguration(Action<IConfigurationBuilder> configureDelegate)
+    {
+        // Run this immediately so that we can capture the host configuration
+        // before we pass it to the application. We can do this for app configuration
+        // as well if it becomes necessary.
+        configureDelegate(_hostConfiguration);
+        return this;
+    }
+
+    public IHostBuilder ConfigureServices(
+        Action<HostBuilderContext, IServiceCollection> configureDelegate
+    )
+    {
+        _configure += b => b.ConfigureServices(configureDelegate);
+        return this;
+    }
+
+    public IHostBuilder UseServiceProviderFactory<TContainerBuilder>(
+        IServiceProviderFactory<TContainerBuilder> factory
+    )
+        where TContainerBuilder : notnull
+    {
+        _configure += b => b.UseServiceProviderFactory(factory);
+        return this;
+    }
+
+    public IHostBuilder UseServiceProviderFactory<TContainerBuilder>(
+        Func<HostBuilderContext, IServiceProviderFactory<TContainerBuilder>> factory
+    )
+        where TContainerBuilder : notnull
+    {
+        _configure += b => b.UseServiceProviderFactory(factory);
+        return this;
+    }
+
+    public void ConfigureHostBuilder(object hostBuilder) => _configure((IHostBuilder)hostBuilder);
+
+    public void EntryPointCompleted(Exception? exception)
+    {
+        // If the entry point completed we'll set the tcs just in case the application doesn't call
+        // IHost.Start/StartAsync.
+        if (exception is not null)
+        {
+            _hostStartTcs.TrySetException(exception);
+        }
+        else
+        {
+            _hostStartTcs.TrySetResult();
+        }
+
+        _entryPointCompletionTcs.TrySetResult(exception);
+    }
+
+    public void SetHostFactory(Func<string[], object> hostFactory) => _hostFactory = hostFactory;
+
+    private sealed class DeferredHost(
+        IHost host,
+        TaskCompletionSource hostStartedTcs,
+        IHostApplicationLifetime applicationLifetime
+    ) : IHost, IAsyncDisposable
+    {
+        public async ValueTask DisposeAsync()
+        {
+            if (host is IAsyncDisposable disposable)
+            {
+                await disposable.DisposeAsync().ConfigureAwait(false);
+                return;
+            }
+
+            Dispose();
+        }
+
+        public IServiceProvider Services => host.Services;
+
+        public void Dispose() => host.Dispose();
+
+        public async Task StartAsync(CancellationToken cancellationToken = default)
+        {
+            // Wait on the existing host to start running and have this call wait on that. This
+            // avoids starting the actual host too early and
+            // leaves the application in charge of calling start.
+
+            await using var reg = cancellationToken.UnsafeRegister(
+                _ => hostStartedTcs.TrySetCanceled(),
+                null
+            );
+
+            // Wait on the existing host to start running and have this call wait on that. This
+            // avoids starting the actual host too early and
+            // leaves the application in charge of calling start.
+            await using var reg2 = applicationLifetime.ApplicationStarted.UnsafeRegister(
+                _ => hostStartedTcs.TrySetResult(),
+                null
+            );
+
+            await hostStartedTcs.Task.ConfigureAwait(false);
+        }
+
+        public Task StopAsync(CancellationToken cancellationToken = default) =>
+            host.StopAsync(cancellationToken);
+    }
+}
diff --git a/src/MinimalLambda.Testing/DictionaryExtensions.cs b/src/MinimalLambda.Testing/DictionaryExtensions.cs
new file mode 100644
index 00000000..ea887fc8
--- /dev/null
+++ b/src/MinimalLambda.Testing/DictionaryExtensions.cs
@@ -0,0 +1,19 @@
+namespace MinimalLambda.Testing;
+
+internal static class DictionaryExtensions
+{
+    extension<TKey, TValue>(IDictionary<TKey, TValue> dictionary)
+    {
+        internal void AddRequired(TKey key, TValue value)
+        {
+            if (!dictionary.TryAdd(key, value))
+                throw new InvalidOperationException($"Key '{key}' already exists.");
+        }
+
+        internal void GetRequired(TKey? key, out TValue value)
+        {
+            if (key is null || !dictionary.TryGetValue(key, out value!))
+                throw new InvalidOperationException($"Key '{key}' is null or does not exist.");
+        }
+    }
+}
diff --git a/src/MinimalLambda.Testing/HostFactoryResolver.cs b/src/MinimalLambda.Testing/HostFactoryResolver.cs
new file mode 100644
index 00000000..ff6a46ed
--- /dev/null
+++ b/src/MinimalLambda.Testing/HostFactoryResolver.cs
@@ -0,0 +1,365 @@
+// This file is copied from runtime
+// Source:
+// https://github.com/dotnet/runtime/blob/v10.0.0/src/libraries/Microsoft.Extensions.HostFactoryResolver/src/HostFactoryResolver.cs
+// Copyright (c) .NET Foundation and Contributors
+// Licensed under the MIT License
+// See THIRD-PARTY-LICENSES.txt file in the project root or visit
+// https://github.com/dotnet/aspnetcore/blob/v10.0.0/LICENSE.txt
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Reflection;
+
+namespace Microsoft.Extensions.Hosting;
+
+internal sealed class HostFactoryResolver
+{
+    private const BindingFlags DeclaredOnlyLookup =
+        BindingFlags.Public
+        | BindingFlags.NonPublic
+        | BindingFlags.Instance
+        | BindingFlags.Static
+        | BindingFlags.DeclaredOnly;
+
+    public const string BuildWebHost = nameof(BuildWebHost);
+    public const string CreateWebHostBuilder = nameof(CreateWebHostBuilder);
+    public const string CreateHostBuilder = nameof(CreateHostBuilder);
+
+    private const string TimeoutEnvironmentKey =
+        "DOTNET_HOST_FACTORY_RESOLVER_DEFAULT_TIMEOUT_IN_SECONDS";
+
+    // The amount of time we wait for the diagnostic source events to fire
+    private static readonly TimeSpan s_defaultWaitTimeout = SetupDefaultTimeout();
+
+    private static TimeSpan SetupDefaultTimeout()
+    {
+        if (Debugger.IsAttached)
+            return Timeout.InfiniteTimeSpan;
+
+        if (
+            uint.TryParse(
+                Environment.GetEnvironmentVariable(TimeoutEnvironmentKey),
+                out var timeoutInSeconds
+            )
+        )
+            return TimeSpan.FromSeconds((int)timeoutInSeconds);
+
+        return TimeSpan.FromMinutes(5);
+    }
+
+    public static Func<string[], TWebHost>? ResolveWebHostFactory<TWebHost>(Assembly assembly) =>
+        ResolveFactory<TWebHost>(assembly, BuildWebHost);
+
+    public static Func<string[], TWebHostBuilder>? ResolveWebHostBuilderFactory<TWebHostBuilder>(
+        Assembly assembly
+    ) => ResolveFactory<TWebHostBuilder>(assembly, CreateWebHostBuilder);
+
+    public static Func<string[], THostBuilder>? ResolveHostBuilderFactory<THostBuilder>(
+        Assembly assembly
+    ) => ResolveFactory<THostBuilder>(assembly, CreateHostBuilder);
+
+    // This helpers encapsulates all of the complex logic required to:
+    // 1. Execute the entry point of the specified assembly in a different thread.
+    // 2. Wait for the diagnostic source events to fire
+    // 3. Give the caller a chance to execute logic to mutate the IHostBuilder
+    // 4. Resolve the instance of the applications's IHost
+    // 5. Allow the caller to determine if the entry point has completed
+    public static Func<string[], object>? ResolveHostFactory(
+        Assembly assembly,
+        TimeSpan? waitTimeout = null,
+        bool stopApplication = true,
+        Action<object>? configureHostBuilder = null,
+        Action<Exception?>? entrypointCompleted = null
+    )
+    {
+        if (assembly.EntryPoint is null)
+            return null;
+
+        try
+        {
+            // Attempt to load hosting and check the version to make sure the events
+            // even have a chance of firing (they were added in .NET >= 6)
+            var hostingAssembly = Assembly.Load("Microsoft.Extensions.Hosting");
+            if (hostingAssembly.GetName().Version is Version version && version.Major < 6)
+                return null;
+
+            // We're using a version >= 6 so the events can fire. If they don't fire
+            // then it's because the application isn't using the hosting APIs
+        }
+        catch
+        {
+            // There was an error loading the extensions assembly, return null.
+            return null;
+        }
+
+        return args =>
+            new HostingListener(
+                args,
+                assembly.EntryPoint,
+                waitTimeout ?? s_defaultWaitTimeout,
+                stopApplication,
+                configureHostBuilder,
+                entrypointCompleted
+            ).CreateHost();
+    }
+
+    private static Func<string[], T>? ResolveFactory<T>(Assembly assembly, string name)
+    {
+        var programType = assembly?.EntryPoint?.DeclaringType;
+        if (programType == null)
+            return null;
+
+        var factory = programType.GetMethod(name, DeclaredOnlyLookup);
+        if (!IsFactory<T>(factory))
+            return null;
+
+        return args => (T)factory!.Invoke(null, new object[] { args })!;
+    }
+
+    // TReturn Factory(string[] args);
+    private static bool IsFactory<TReturn>(MethodInfo? factory) =>
+        factory != null
+        && typeof(TReturn).IsAssignableFrom(factory.ReturnType)
+        && factory.GetParameters().Length == 1
+        && typeof(string[]).Equals(factory.GetParameters()[0].ParameterType);
+
+    // Used by EF tooling without any Hosting references. Looses some return type safety checks.
+    public static Func<string[], IServiceProvider?>? ResolveServiceProviderFactory(
+        Assembly assembly,
+        TimeSpan? waitTimeout = null
+    )
+    {
+        // Prefer the older patterns by default for back compat.
+        var webHostFactory = ResolveWebHostFactory<object>(assembly);
+        if (webHostFactory != null)
+            return args =>
+            {
+                var webHost = webHostFactory(args);
+                return GetServiceProvider(webHost);
+            };
+
+        var webHostBuilderFactory = ResolveWebHostBuilderFactory<object>(assembly);
+        if (webHostBuilderFactory != null)
+            return args =>
+            {
+                var webHostBuilder = webHostBuilderFactory(args);
+                var webHost = Build(webHostBuilder);
+                return GetServiceProvider(webHost);
+            };
+
+        var hostBuilderFactory = ResolveHostBuilderFactory<object>(assembly);
+        if (hostBuilderFactory != null)
+            return args =>
+            {
+                var hostBuilder = hostBuilderFactory(args);
+                var host = Build(hostBuilder);
+                return GetServiceProvider(host);
+            };
+
+        var hostFactory = ResolveHostFactory(assembly, waitTimeout);
+        if (hostFactory != null)
+            return args =>
+            {
+                static bool IsApplicationNameArg(string arg) =>
+                    arg.Equals("--applicationName", StringComparison.OrdinalIgnoreCase)
+                    || arg.Equals("/applicationName", StringComparison.OrdinalIgnoreCase);
+
+                if (
+                    !args.Any(arg => IsApplicationNameArg(arg))
+                    && assembly.GetName().Name is string assemblyName
+                )
+                    args = args.Concat(new[] { "--applicationName", assemblyName }).ToArray();
+
+                var host = hostFactory(args);
+                return GetServiceProvider(host);
+            };
+
+        return null;
+    }
+
+    private static object? Build(object builder)
+    {
+        var buildMethod = builder.GetType().GetMethod("Build");
+        return buildMethod?.Invoke(builder, Array.Empty<object>());
+    }
+
+    private static IServiceProvider? GetServiceProvider(object? host)
+    {
+        if (host == null)
+            return null;
+        var hostType = host.GetType();
+        var servicesProperty = hostType.GetProperty("Services", DeclaredOnlyLookup);
+        return (IServiceProvider?)servicesProperty?.GetValue(host);
+    }
+
+    private sealed class HostingListener
+        : IObserver<DiagnosticListener>,
+            IObserver<KeyValuePair<string, object?>>
+    {
+        private static readonly AsyncLocal<HostingListener> _currentListener = new();
+        private readonly string[] _args;
+        private readonly Action<object>? _configure;
+        private readonly MethodInfo _entryPoint;
+        private readonly Action<Exception?>? _entrypointCompleted;
+
+        private readonly TaskCompletionSource<object> _hostTcs = new();
+        private readonly bool _stopApplication;
+        private readonly TimeSpan _waitTimeout;
+        private IDisposable? _disposable;
+
+        public HostingListener(
+            string[] args,
+            MethodInfo entryPoint,
+            TimeSpan waitTimeout,
+            bool stopApplication,
+            Action<object>? configure,
+            Action<Exception?>? entrypointCompleted
+        )
+        {
+            _args = args;
+            _entryPoint = entryPoint;
+            _waitTimeout = waitTimeout;
+            _stopApplication = stopApplication;
+            _configure = configure;
+            _entrypointCompleted = entrypointCompleted;
+        }
+
+        public void OnCompleted() => _disposable?.Dispose();
+
+        public void OnError(Exception error) { }
+
+        public void OnNext(DiagnosticListener value)
+        {
+            if (_currentListener.Value != this)
+                // Ignore events that aren't for this listener
+                return;
+
+            if (value.Name == "Microsoft.Extensions.Hosting")
+                _disposable = value.Subscribe(this);
+        }
+
+        public void OnNext(KeyValuePair<string, object?> value)
+        {
+            if (_currentListener.Value != this)
+                // Ignore events that aren't for this listener
+                return;
+
+            if (value.Key == "HostBuilding")
+                _configure?.Invoke(value.Value!);
+
+            if (value.Key == "HostBuilt")
+            {
+                _hostTcs.TrySetResult(value.Value!);
+
+                if (_stopApplication)
+                    // Stop the host from running further
+                    ThrowHostAborted();
+            }
+        }
+
+        public object CreateHost()
+        {
+            using var subscription = DiagnosticListener.AllListeners.Subscribe(this);
+
+            // Kick off the entry point on a new thread so we don't block the current one
+            // in case we need to timeout the execution
+            var thread = new Thread(() =>
+            {
+                Exception? exception = null;
+
+                try
+                {
+                    // Set the async local to the instance of the HostingListener so we can
+                    // filter events that
+                    // aren't scoped to this execution of the entry point.
+                    _currentListener.Value = this;
+
+                    var parameters = _entryPoint.GetParameters();
+                    if (parameters.Length == 0)
+                        _entryPoint.Invoke(null, Array.Empty<object>());
+                    else
+                        _entryPoint.Invoke(null, new object[] { _args });
+
+                    // Try to set an exception if the entry point returns gracefully, this
+                    // will force
+                    // build to throw
+                    _hostTcs.TrySetException(
+                        new InvalidOperationException(
+                            "The entry point exited without ever building an IHost."
+                        )
+                    );
+                }
+                catch (TargetInvocationException tie)
+                    when (tie.InnerException?.GetType().Name == "HostAbortedException")
+                {
+                    // The host was stopped by our own logic
+                }
+                catch (TargetInvocationException tie)
+                {
+                    exception = tie.InnerException ?? tie;
+
+                    // Another exception happened, propagate that to the caller
+                    _hostTcs.TrySetException(exception);
+                }
+                catch (Exception ex)
+                {
+                    exception = ex;
+
+                    // Another exception happened, propagate that to the caller
+                    _hostTcs.TrySetException(ex);
+                }
+                finally
+                {
+                    // Signal that the entry point is completed
+                    _entrypointCompleted?.Invoke(exception);
+                }
+            })
+            {
+                // Make sure this doesn't hang the process
+                IsBackground = true,
+            };
+
+            // Start the thread
+            thread.Start();
+
+            try
+            {
+                // Wait before throwing an exception
+                if (!_hostTcs.Task.Wait(_waitTimeout))
+                    throw new InvalidOperationException(
+                        $"Timed out waiting for the entry point to build the IHost after {s_defaultWaitTimeout}. This timeout can be modified using the '{TimeoutEnvironmentKey}' environment variable."
+                    );
+            }
+            catch (AggregateException) when (_hostTcs.Task.IsCompleted)
+            {
+                // Lets this propagate out of the call to GetAwaiter().GetResult()
+            }
+
+            Debug.Assert(_hostTcs.Task.IsCompleted);
+
+            return _hostTcs.Task.GetAwaiter().GetResult();
+        }
+
+        // HostFactoryResolver is used by tools that explicitly don't want to reference
+        // Microsoft.Extensions.Hosting assemblies.
+        // So don't depend on the public HostAbortedException directly. Instead, load the exception
+        // type dynamically if it can
+        // be found. If it can't (possibly because the app is using an older version), throw a
+        // private exception with the same name.
+        private void ThrowHostAborted()
+        {
+            var publicHostAbortedExceptionType = Type.GetType(
+                "Microsoft.Extensions.Hosting.HostAbortedException, Microsoft.Extensions.Hosting.Abstractions",
+                false
+            );
+            if (publicHostAbortedExceptionType != null)
+                throw (Exception)Activator.CreateInstance(publicHostAbortedExceptionType)!;
+
+            throw new HostAbortedException();
+        }
+
+        private sealed class HostAbortedException : Exception { }
+    }
+}
diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs
new file mode 100644
index 00000000..79d7f337
--- /dev/null
+++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs
@@ -0,0 +1,531 @@
+// Portions of this file are derived from aspnetcore
+// Source:
+// https://github.com/dotnet/aspnetcore/blob/v10.0.0/src/Mvc/Mvc.Testing/src/LambdaApplicationFactory.cs
+// Copyright (c) .NET Foundation and Contributors
+// Licensed under the MIT License
+// See THIRD-PARTY-LICENSES.txt file in the project root or visit
+// https://github.com/dotnet/aspnetcore/blob/v10.0.0/LICENSE.txt
+
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyModel;
+using Microsoft.Extensions.Hosting;
+using MinimalLambda.Builder.Extensions;
+using MinimalLambda.Options;
+
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// Factory for bootstrapping an application in memory for functional end to end tests.
+/// </summary>
+/// <typeparam name="TEntryPoint">A type in the entry point assembly of the application.
+/// Typically the Startup or Program classes can be used.</typeparam>
+public class LambdaApplicationFactory<TEntryPoint> : IDisposable, IAsyncDisposable
+    where TEntryPoint : class
+{
+    private readonly List<LambdaApplicationFactory<TEntryPoint>> _derivedFactories = [];
+    private Action<IHostBuilder> _configuration;
+    private bool _disposed;
+    private bool _disposedAsync;
+    private IHost? _host;
+    private LambdaTestServer? _server;
+    private CancellationToken? _stoppingToken;
+
+    /// <summary>
+    /// <para>
+    /// Creates an instance of <see cref="LambdaApplicationFactory{TEntryPoint}"/>. This factory can be used to
+    /// create a <see cref="LambdaTestServer"/> instance for testing Lambda applications defined by <typeparamref name="TEntryPoint"/>
+    /// in-memory without deploying to AWS.
+    /// The <see cref="LambdaApplicationFactory{TEntryPoint}"/> will find the entry point class of <typeparamref name="TEntryPoint"/>
+    /// assembly and initialize the application by calling <c>IHostBuilder CreateHostBuilder(string[] args)</c>
+    /// on <typeparamref name="TEntryPoint"/>.
+    /// </para>
+    /// <para>
+    /// This constructor will infer the application content root path by searching for a
+    /// <see cref="LambdaApplicationFactoryContentRootAttribute"/> on the assembly containing the functional tests with
+    /// a key equal to the <typeparamref name="TEntryPoint"/> assembly <see cref="Assembly.FullName"/>.
+    /// In case an attribute with the right key can't be found, <see cref="LambdaApplicationFactory{TEntryPoint}"/>
+    /// will fall back to searching for a solution file (*.sln) and then appending <typeparamref name="TEntryPoint"/> assembly name
+    /// to the solution directory. The application root directory will be used to discover views and content files.
+    /// </para>
+    /// <para>
+    /// The application assemblies will be loaded from the dependency context of the assembly containing
+    /// <typeparamref name="TEntryPoint" />. This means that project dependencies of the assembly containing
+    /// <typeparamref name="TEntryPoint" /> will be loaded as application assemblies.
+    /// </para>
+    /// </summary>
+    public LambdaApplicationFactory() => _configuration = ConfigureWebHost;
+
+    /// <summary>
+    /// Gets the <see cref="IReadOnlyList{LambdaApplicationFactory}"/> of factories created from this factory
+    /// by further customizing the <see cref="IHostBuilder"/> when calling
+    /// <see cref="WithHostBuilder"/>.
+    /// </summary>
+    public IReadOnlyList<LambdaApplicationFactory<TEntryPoint>> Factories =>
+        _derivedFactories.AsReadOnly();
+
+    /// <summary>
+    /// Gets the <see cref="LambdaServerOptions"/> used to configure the <see cref="LambdaTestServer"/>.
+    /// These options control Lambda-specific testing behavior such as function timeout, ARN, and custom headers
+    /// included in Lambda runtime HTTP responses.
+    /// </summary>
+    public LambdaServerOptions ServerOptions { get; private init; } = new();
+
+    /// <summary>
+    /// Gets the <see cref="IServiceProvider"/> created by the server associated with this <see cref="LambdaApplicationFactory{TEntryPoint}"/>.
+    /// </summary>
+    public virtual IServiceProvider Services => TestServer.Services;
+
+    /// <summary>
+    /// Gets the <see cref="LambdaTestServer"/> created by this <see cref="LambdaApplicationFactory{TEntryPoint}"/>.
+    /// </summary>
+    public LambdaTestServer TestServer
+    {
+        get
+        {
+            EnsureServer();
+            return _server;
+        }
+    }
+
+    /// <inheritdoc />
+    public virtual async ValueTask DisposeAsync()
+    {
+        if (_disposed)
+            return;
+
+        if (_disposedAsync)
+            return;
+
+        foreach (var factory in _derivedFactories)
+            await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false);
+
+        // TestServer handles disposing both processor and host
+        if (_server != null)
+            await _server.DisposeAsync().ConfigureAwait(false);
+
+        _host?.Dispose();
+
+        _disposedAsync = true;
+
+        Dispose(true);
+
+        GC.SuppressFinalize(this);
+    }
+
+    /// <inheritdoc />
+    public void Dispose()
+    {
+        Dispose(true);
+        GC.SuppressFinalize(this);
+    }
+
+    /// <summary>
+    /// Configures a cancellation token to propagate test cancellation signals to the Lambda test server
+    /// and its underlying components.
+    /// </summary>
+    /// <param name="cancellationToken">
+    /// A <see cref="CancellationToken"/> that will be propagated to the test server, allowing it to
+    /// respond more efficiently to cancellation signals during test execution.
+    /// </param>
+    /// <returns>
+    /// The current <see cref="LambdaApplicationFactory{TEntryPoint}"/> instance for method chaining.
+    /// </returns>
+    /// <remarks>
+    /// This method allows test frameworks to signal cancellation to the Lambda test infrastructure,
+    /// enabling graceful shutdown and faster test cleanup when tests are cancelled or time out.
+    /// The cancellation token is passed to the <see cref="LambdaTestServer"/> during creation.
+    /// </remarks>
+    public LambdaApplicationFactory<TEntryPoint> WithCancellationToken(
+        CancellationToken cancellationToken
+    )
+    {
+        _stoppingToken = cancellationToken;
+        return this;
+    }
+
+    /// <summary>
+    /// Finalizes an instance of the <see cref="LambdaApplicationFactory{TEntryPoint}"/> class.
+    /// </summary>
+    ~LambdaApplicationFactory() => Dispose(false);
+
+    /// <summary>
+    /// Creates a new <see cref="LambdaApplicationFactory{TEntryPoint}"/> with a <see cref="IHostBuilder"/>
+    /// that is further customized by <paramref name="configuration"/>.
+    /// </summary>
+    /// <param name="configuration">
+    /// An <see cref="Action{IHostBuilder}"/> to configure the <see cref="IHostBuilder"/>.
+    /// </param>
+    /// <returns>A new <see cref="LambdaApplicationFactory{TEntryPoint}"/>.</returns>
+    public LambdaApplicationFactory<TEntryPoint> WithHostBuilder(
+        Action<IHostBuilder> configuration
+    ) => WithHostBuilderCore(configuration);
+
+    /// <summary>
+    /// Core implementation of <see cref="WithHostBuilder"/> that creates a derived factory with additional configuration.
+    /// This method creates a <see cref="DelegatedLambdaApplicationFactory"/> that chains the parent factory's configuration
+    /// with the new configuration provided in <paramref name="configuration"/>. The derived factory is tracked in the
+    /// <see cref="Factories"/> list for proper disposal.
+    /// </summary>
+    /// <param name="configuration">
+    /// An <see cref="Action{IHostBuilder}"/> to configure the <see cref="IHostBuilder"/>.
+    /// This configuration will be applied after the parent factory's configuration.
+    /// </param>
+    /// <returns>
+    /// A new <see cref="LambdaApplicationFactory{TEntryPoint}"/> that applies both the parent factory's
+    /// configuration and the additional configuration specified in <paramref name="configuration"/>.
+    /// </returns>
+    /// <remarks>
+    /// This method is <see langword="virtual"/> to allow derived classes to customize the factory creation behavior.
+    /// </remarks>
+    protected virtual LambdaApplicationFactory<TEntryPoint> WithHostBuilderCore(
+        Action<IHostBuilder> configuration
+    )
+    {
+        var factory = new DelegatedLambdaApplicationFactory(
+            ServerOptions,
+            CreateHost,
+            GetTestAssemblies,
+            builder =>
+            {
+                _configuration(builder);
+                configuration(builder);
+            }
+        );
+
+        _derivedFactories.Add(factory);
+
+        return factory;
+    }
+
+    [MemberNotNull(nameof(_server))]
+    private void EnsureServer()
+    {
+        if (_server != null)
+            return;
+
+        EnsureDepsFile();
+
+        var deferredHostBuilder = new DeferredHostBuilder();
+        deferredHostBuilder.UseEnvironment(Environments.Development);
+        // There's no helper for UseApplicationName, but we need to
+        // set the application name to the target entry point
+        // assembly name.
+        deferredHostBuilder.ConfigureHostConfiguration(config =>
+        {
+            config.AddInMemoryCollection(
+                new Dictionary<string, string?>
+                {
+                    {
+                        HostDefaults.ApplicationKey,
+                        typeof(TEntryPoint).Assembly.GetName().Name ?? string.Empty
+                    },
+                }
+            );
+        });
+        // This helper call does the hard work to determine if we can fallback to diagnostic
+        // source events to get the host instance
+        var factory = HostFactoryResolver.ResolveHostFactory(
+            typeof(TEntryPoint).Assembly,
+            stopApplication: false,
+            configureHostBuilder: deferredHostBuilder.ConfigureHostBuilder,
+            entrypointCompleted: deferredHostBuilder.EntryPointCompleted
+        );
+
+        if (factory is not null)
+        {
+            // If we have a valid factory it means the specified entry point's assembly can
+            // potentially resolve the IHost
+            // so we set the factory on the DeferredHostBuilder so we can invoke it on the call
+            // to IHostBuilder.Build.
+            deferredHostBuilder.SetHostFactory(factory);
+
+            ConfigureHostBuilder(deferredHostBuilder, deferredHostBuilder.EntryPointCompletion);
+            return;
+        }
+
+        throw new InvalidOperationException("Unable to create IHostBuilder instance.");
+    }
+
+    [MemberNotNull(nameof(_server))]
+    private void ConfigureHostBuilder(
+        IHostBuilder hostBuilder,
+        Task<Exception?> entryPointCompletion
+    )
+    {
+        SetContentRoot(hostBuilder);
+        _configuration(hostBuilder);
+
+        _server = new LambdaTestServer(
+            entryPointCompletion,
+            ServerOptions,
+            _stoppingToken ?? CancellationToken.None
+        );
+
+        // set Lambda Bootstrap Http Client
+        hostBuilder.ConfigureServices(services =>
+        {
+            services.AddLambdaBootstrapHttpClient(new HttpClient(_server.CreateHandler()));
+
+            services.PostConfigure<LambdaHostOptions>(options =>
+            {
+                if (string.IsNullOrEmpty(options.BootstrapOptions.RuntimeApiEndpoint))
+                    options.BootstrapOptions.RuntimeApiEndpoint = "localhost";
+            });
+        });
+
+        // Build the host but DON'T start it - server will start it
+        _host = CreateHost(hostBuilder);
+        _server.SetHost(_host);
+    }
+
+    private void SetContentRoot(IHostBuilder builder)
+    {
+        var contentRoot = GetContentRootFromAssembly();
+
+        if (contentRoot != null)
+            builder.UseContentRoot(contentRoot);
+        else
+            UseSolutionRelativeContentRoot(builder, typeof(TEntryPoint).Assembly.GetName().Name!);
+    }
+
+    private static void UseSolutionRelativeContentRoot(
+        IHostBuilder builder,
+        string solutionRelativePath
+    )
+    {
+        ArgumentNullException.ThrowIfNull(solutionRelativePath);
+
+        var applicationBasePath = AppContext.BaseDirectory;
+        string[] solutionNames = ["*.sln", "*.slnx"];
+
+        var directoryInfo = new DirectoryInfo(applicationBasePath);
+        do
+        {
+            foreach (var solutionName in solutionNames)
+            {
+                var solutionPath = Directory
+                    .EnumerateFiles(directoryInfo.FullName, solutionName)
+                    .FirstOrDefault();
+                if (solutionPath != null)
+                {
+                    builder.UseContentRoot(
+                        Path.GetFullPath(Path.Combine(directoryInfo.FullName, solutionRelativePath))
+                    );
+                    return;
+                }
+            }
+
+            directoryInfo = directoryInfo.Parent;
+        } while (directoryInfo is not null);
+
+        throw new InvalidOperationException(
+            $"Solution root could not be located using application root {applicationBasePath}."
+        );
+    }
+
+    private string? GetContentRootFromAssembly()
+    {
+        var metadataAttributes = GetContentRootMetadataAttributes(
+            typeof(TEntryPoint).Assembly.FullName!,
+            typeof(TEntryPoint).Assembly.GetName().Name!
+        );
+
+        string? contentRoot = null;
+        foreach (var contentRootAttribute in metadataAttributes)
+        {
+            var contentRootCandidate = Path.Combine(
+                AppContext.BaseDirectory,
+                contentRootAttribute.ContentRootPath
+            );
+
+            var contentRootMarker = Path.Combine(
+                contentRootCandidate,
+                Path.GetFileName(contentRootAttribute.ContentRootTest)
+            );
+
+            if (File.Exists(contentRootMarker))
+            {
+                contentRoot = contentRootCandidate;
+                break;
+            }
+        }
+
+        return contentRoot;
+    }
+
+    private LambdaApplicationFactoryContentRootAttribute[] GetContentRootMetadataAttributes(
+        string tEntryPointAssemblyFullName,
+        string tEntryPointAssemblyName
+    )
+    {
+        var testAssembly = GetTestAssemblies();
+        var metadataAttributes = testAssembly
+            .SelectMany(a => a.GetCustomAttributes<LambdaApplicationFactoryContentRootAttribute>())
+            .Where(a =>
+                string.Equals(
+                    a.Key,
+                    tEntryPointAssemblyFullName,
+                    StringComparison.OrdinalIgnoreCase
+                )
+                || string.Equals(a.Key, tEntryPointAssemblyName, StringComparison.OrdinalIgnoreCase)
+            )
+            .OrderBy(a => a.Priority)
+            .ToArray();
+
+        return metadataAttributes;
+    }
+
+    /// <summary>
+    /// Gets the assemblies containing the functional tests. The
+    /// <see cref="LambdaApplicationFactoryContentRootAttribute"/> applied to these
+    /// assemblies defines the content root to use for the given
+    /// <typeparamref name="TEntryPoint"/>.
+    /// </summary>
+    /// <returns>The list of <see cref="Assembly"/> containing tests.</returns>
+    protected virtual IEnumerable<Assembly> GetTestAssemblies()
+    {
+        try
+        {
+            // The default dependency context will be populated in .net core applications.
+            var context = DependencyContext.Default;
+            if (context == null || context.CompileLibraries.Count == 0)
+                // The app domain friendly name will be populated in full framework.
+                return [Assembly.Load(AppDomain.CurrentDomain.FriendlyName)];
+
+            var runtimeProjectLibraries = context.RuntimeLibraries.ToDictionary(
+                r => r.Name,
+                r => r,
+                StringComparer.Ordinal
+            );
+
+            // Find the list of projects
+            _ = context.CompileLibraries.Where(l => l.Type == "project");
+
+            var entryPointAssemblyName = typeof(TEntryPoint).Assembly.GetName().Name;
+
+            // Find the list of projects referencing TEntryPoint.
+            var candidates = context.CompileLibraries.Where(library =>
+                library.Dependencies.Any(d =>
+                    string.Equals(d.Name, entryPointAssemblyName, StringComparison.Ordinal)
+                )
+            );
+
+            var testAssemblies = new List<Assembly>();
+            foreach (var candidate in candidates)
+                if (runtimeProjectLibraries.TryGetValue(candidate.Name, out var runtimeLibrary))
+                {
+                    var runtimeAssemblies = runtimeLibrary.GetDefaultAssemblyNames(context);
+                    testAssemblies.AddRange(runtimeAssemblies.Select(Assembly.Load));
+                }
+
+            return testAssemblies;
+        }
+        catch (Exception)
+        {
+            // Ignore
+        }
+
+        return [];
+    }
+
+    private static void EnsureDepsFile()
+    {
+        if (typeof(TEntryPoint).Assembly.EntryPoint == null)
+            throw new InvalidOperationException(
+                $"Invalid assembly entry point: {typeof(TEntryPoint).Assembly.FullName}"
+            );
+
+        var depsFileName = $"{typeof(TEntryPoint).Assembly.GetName().Name}.deps.json";
+        var depsFile = new FileInfo(Path.Combine(AppContext.BaseDirectory, depsFileName));
+        if (!depsFile.Exists)
+            throw new InvalidOperationException($"Missing deps file: {depsFile.FullName}");
+    }
+
+    /// <summary>
+    /// Creates the <see cref="IHost"/> with the bootstrapped application in <paramref name="builder"/>.
+    /// The host is built but not started. The <see cref="LambdaTestServer"/> will start the host
+    /// when <see cref="LambdaTestServer.StartAsync"/> is called.
+    /// </summary>
+    /// <param name="builder">The <see cref="IHostBuilder"/> used to create the host.</param>
+    /// <returns>The <see cref="IHost"/> with the bootstrapped application.</returns>
+    protected virtual IHost CreateHost(IHostBuilder builder) =>
+        // Build the host but DON'T start it - LambdaTestServer.StartAsync() will start it
+        builder.Build();
+
+    /// <summary>
+    /// Gives a fixture an opportunity to configure the application before it gets built.
+    /// </summary>
+    /// <param name="builder">The <see cref="IHostBuilder"/> for the application.</param>
+    protected virtual void ConfigureWebHost(IHostBuilder builder) { }
+
+    /// <summary>
+    /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+    /// </summary>
+    /// <param name="disposing">
+    /// <see langword="true" /> to release both managed and unmanaged resources;
+    /// <see langword="false" /> to release only unmanaged resources.
+    /// </param>
+    protected virtual void Dispose(bool disposing)
+    {
+        if (_disposed)
+            return;
+
+        if (disposing && !_disposedAsync)
+            DisposeAsync().AsTask().ConfigureAwait(false).GetAwaiter().GetResult();
+
+        _disposed = true;
+    }
+
+    /// <summary>
+    /// Internal implementation of <see cref="LambdaApplicationFactory{TEntryPoint}"/> that delegates virtual method calls
+    /// to callbacks provided at construction. This class is used by <see cref="LambdaApplicationFactory{TEntryPoint}.WithHostBuilder"/> to create derived
+    /// factories with customized behavior without creating additional subclasses.
+    /// </summary>
+    /// <remarks>
+    /// This class implements the delegation pattern to allow runtime customization of virtual methods.
+    /// Each virtual method (<see cref="CreateHost"/>, <see cref="GetTestAssemblies"/>,
+    /// and <see cref="ConfigureWebHost"/>) delegates to a callback function provided in the constructor,
+    /// enabling configuration chaining while reusing the base factory infrastructure.
+    /// </remarks>
+    private sealed class DelegatedLambdaApplicationFactory : LambdaApplicationFactory<TEntryPoint>
+    {
+        private readonly Func<IHostBuilder, IHost> _createHost;
+        private readonly Func<IEnumerable<Assembly>> _getTestAssemblies;
+
+        public DelegatedLambdaApplicationFactory(
+            LambdaServerOptions options,
+            Func<IHostBuilder, IHost> createHost,
+            Func<IEnumerable<Assembly>> getTestAssemblies,
+            Action<IHostBuilder> configureWebHost
+        )
+        {
+            ServerOptions = options;
+            _createHost = createHost;
+            _getTestAssemblies = getTestAssemblies;
+            _configuration = configureWebHost;
+        }
+
+        protected override IHost CreateHost(IHostBuilder builder) => _createHost(builder);
+
+        protected override IEnumerable<Assembly> GetTestAssemblies() => _getTestAssemblies();
+
+        protected override void ConfigureWebHost(IHostBuilder builder) => _configuration(builder);
+
+        protected override LambdaApplicationFactory<TEntryPoint> WithHostBuilderCore(
+            Action<IHostBuilder> configuration
+        ) =>
+            new DelegatedLambdaApplicationFactory(
+                ServerOptions,
+                _createHost,
+                _getTestAssemblies,
+                builder =>
+                {
+                    _configuration(builder);
+                    configuration(builder);
+                }
+            );
+    }
+}
diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactoryContentRootAttribute.cs b/src/MinimalLambda.Testing/LambdaApplicationFactoryContentRootAttribute.cs
new file mode 100644
index 00000000..b79a883f
--- /dev/null
+++ b/src/MinimalLambda.Testing/LambdaApplicationFactoryContentRootAttribute.cs
@@ -0,0 +1,91 @@
+// Portions of this file are derived from aspnetcore
+// Source:
+// https://github.com/dotnet/aspnetcore/blob/v10.0.0/src/Mvc/Mvc.Testing/src/LambdaApplicationFactoryContentRootAttribute.cs
+// Copyright (c) .NET Foundation and Contributors
+// Licensed under the MIT License
+// See THIRD-PARTY-LICENSES.txt file in the project root or visit
+// https://github.com/dotnet/aspnetcore/blob/v10.0.0/LICENSE.txt
+
+using System.Globalization;
+using System.Reflection;
+
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// Metadata that <see cref="LambdaApplicationFactory{TEntryPoint}"/> uses to find out the content
+/// root for the web application represented by <c>TEntryPoint</c>.
+/// <see cref="LambdaApplicationFactory{TEntryPoint}"/> will iterate over all the instances of
+/// <see cref="LambdaApplicationFactoryContentRootAttribute"/>, filter the instances whose
+/// <see cref="Key"/> is equal to <c>TEntryPoint</c> <see cref="Assembly.FullName"/>,
+/// order them by <see cref="Priority"/> in ascending order.
+/// <see cref="LambdaApplicationFactory{TEntryPoint}"/> will check for the existence of the marker
+/// in <c>Path.Combine(<see cref="ContentRootPath"/>, Path.GetFileName(<see cref="ContentRootTest"/>))"</c>
+/// and if the file exists it will set the content root to <see cref="ContentRootPath"/>.
+/// </summary>
+[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
+public sealed class LambdaApplicationFactoryContentRootAttribute : Attribute
+{
+    /// <summary>
+    /// Initializes a new instance of <see cref="LambdaApplicationFactoryContentRootAttribute"/>.
+    /// </summary>
+    /// <param name="key">
+    /// The key of this <see cref="LambdaApplicationFactoryContentRootAttribute"/>. This
+    /// key is used by <see cref="LambdaApplicationFactory{TEntryPoint}"/> to determine what of the
+    /// <see cref="LambdaApplicationFactoryContentRootAttribute"/> instances on the test assembly should be used
+    /// to match a given TEntryPoint class.
+    /// </param>
+    /// <param name="contentRootPath">The path to the content root. This path can be either relative or absolute.
+    /// In case the path is relative, the path will be combined with
+    /// <see cref="Directory.GetCurrentDirectory()"/></param>
+    /// <param name="contentRootTest">
+    /// A file that will be use as a marker to determine that the content root path for the given context is correct.
+    /// </param>
+    /// <param name="priority">
+    /// The priority of this content root attribute compared to other attributes. When
+    /// multiple <see cref="LambdaApplicationFactoryContentRootAttribute"/> instances are applied for the
+    /// same key, they are processed with <see cref="int.Parse(string)"/>, ordered in ascending order and applied
+    /// in priority until a match is found.
+    /// </param>
+    public LambdaApplicationFactoryContentRootAttribute(
+        string key,
+        string contentRootPath,
+        string contentRootTest,
+        string priority
+    )
+    {
+        Key = key;
+        ContentRootPath = contentRootPath;
+        ContentRootTest = contentRootTest;
+        if (
+            int.TryParse(
+                priority,
+                NumberStyles.Integer,
+                CultureInfo.InvariantCulture,
+                out var parsedPriority
+            )
+        )
+            Priority = parsedPriority;
+    }
+
+    /// <summary>
+    /// Gets the content root path for a given project. This content root can be relative or absolute. If it is a
+    /// relative path, it will be combined with <see cref="AppContext.BaseDirectory"/>.
+    /// </summary>
+    public string ContentRootPath { get; }
+
+    /// <summary>
+    /// A marker file used to ensure that the path the content root is being set to is correct.
+    /// </summary>
+    public string ContentRootTest { get; }
+
+    /// <summary>
+    /// Gets the key for the content root associated with this project. Typically <see cref="Assembly.FullName"/>.
+    /// </summary>
+    public string Key { get; }
+
+    /// <summary>
+    /// Gets a number for determining the probing order when multiple <see cref="LambdaApplicationFactoryContentRootAttribute"/>
+    /// instances with the same key are present on the test <see cref="Assembly"/>.
+    /// </summary>
+    public int Priority { get; }
+}
diff --git a/src/MinimalLambda.Testing/LambdaHttpHandler.cs b/src/MinimalLambda.Testing/LambdaHttpHandler.cs
new file mode 100644
index 00000000..b01b73c4
--- /dev/null
+++ b/src/MinimalLambda.Testing/LambdaHttpHandler.cs
@@ -0,0 +1,63 @@
+using System.Threading.Channels;
+
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// HTTP message handler that intercepts Lambda Bootstrap HTTP calls and
+/// routes them through the test server via transactions.
+/// </summary>
+internal class LambdaTestingHttpHandler(
+    Channel<LambdaHttpTransaction> transactionChannel,
+    CancellationToken stoppingToken
+) : HttpMessageHandler
+{
+    protected override async Task<HttpResponseMessage> SendAsync(
+        HttpRequestMessage request,
+        CancellationToken cancellationToken
+    )
+    {
+        using var cts = CancellationTokenSource.CreateLinkedTokenSource(
+            cancellationToken,
+            stoppingToken
+        );
+
+        // Buffer the content to make it re-readable for downstream consumers
+        if (request.Content != null)
+        {
+            var originalContent = request.Content;
+            var bytes = await originalContent.ReadAsByteArrayAsync(cts.Token);
+            var bufferedContent = new ByteArrayContent(bytes);
+
+            foreach (var header in originalContent.Headers)
+                bufferedContent.Headers.TryAddWithoutValidation(header.Key, header.Value);
+
+            request.Content = bufferedContent;
+            originalContent.Dispose();
+        }
+
+        // Create transaction with request and completion mechanism
+        var transaction = LambdaHttpTransaction.Create(request);
+
+        // Register cancellation to cancel the transaction TCS
+        await using var registration = cts.Token.Register(() => transaction.Cancel());
+
+        // Send transaction to server
+        try
+        {
+            await transactionChannel.Writer.WriteAsync(transaction, cts.Token);
+        }
+        catch (ChannelClosedException)
+        {
+            // TestServer is shutting down; propagate cancellation to caller
+            var canceled = new TaskCompletionSource<HttpResponseMessage>(
+                TaskCreationOptions.RunContinuationsAsynchronously
+            );
+            canceled.TrySetCanceled(cts.Token);
+            return await canceled.Task;
+        }
+
+        // Wait for server to complete the transaction
+        var response = await transaction.ResponseTcs.Task;
+        return response;
+    }
+}
diff --git a/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs b/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs
new file mode 100644
index 00000000..8d474122
--- /dev/null
+++ b/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs
@@ -0,0 +1,84 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.Routing.Template;
+
+namespace MinimalLambda.Testing;
+
+internal static class LambdaRuntimeRouteManager
+{
+    private static readonly RouteTemplate[] Routes =
+    [
+        new()
+        {
+            Type = RequestType.GetNextInvocation,
+            Method = HttpMethod.Get.Method,
+            Matcher = new TemplateMatcher(
+                TemplateParser.Parse("{version}/runtime/invocation/next"),
+                new RouteValueDictionary()
+            ),
+        },
+        new()
+        {
+            Type = RequestType.PostInitError,
+            Method = HttpMethod.Post.Method,
+            Matcher = new TemplateMatcher(
+                TemplateParser.Parse("{version}/runtime/init/error"),
+                new RouteValueDictionary()
+            ),
+        },
+        new()
+        {
+            Type = RequestType.PostResponse,
+            Method = HttpMethod.Post.Method,
+            Matcher = new TemplateMatcher(
+                TemplateParser.Parse("{version}/runtime/invocation/{requestId}/response"),
+                new RouteValueDictionary()
+            ),
+        },
+        new()
+        {
+            Type = RequestType.PostError,
+            Method = HttpMethod.Post.Method,
+            Matcher = new TemplateMatcher(
+                TemplateParser.Parse("{version}/runtime/invocation/{requestId}/error"),
+                new RouteValueDictionary()
+            ),
+        },
+    ];
+
+    internal static bool TryMatch(
+        HttpRequestMessage request,
+        [NotNullWhen(true)] out RequestType? routeType,
+        [NotNullWhen(true)] out RouteValueDictionary? values
+    )
+    {
+        routeType = null;
+        values = null;
+
+        var method = request.Method.Method;
+        var path = request.RequestUri?.AbsolutePath ?? string.Empty;
+
+        foreach (var route in Routes)
+        {
+            if (route.Method != method)
+                continue;
+
+            var routeValues = new RouteValueDictionary();
+            if (route.Matcher.TryMatch(path, routeValues))
+            {
+                routeType = route.Type;
+                values = routeValues;
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private sealed class RouteTemplate
+    {
+        internal required TemplateMatcher Matcher { get; init; }
+        internal required string Method { get; init; }
+        internal required RequestType Type { get; init; }
+    }
+}
diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs
new file mode 100644
index 00000000..a6dec8fd
--- /dev/null
+++ b/src/MinimalLambda.Testing/LambdaTestServer.cs
@@ -0,0 +1,679 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Channels;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using MinimalLambda.Options;
+
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// Provides an in-memory test server that simulates the AWS Lambda runtime environment for testing Lambda functions
+/// without deploying to AWS. This server intercepts HTTP requests from the Lambda bootstrap client, manages the
+/// invocation lifecycle, and provides a public API for test scenarios.
+/// </summary>
+/// <remarks>
+/// <para>
+/// The <see cref="LambdaTestServer"/> follows a strict state machine lifecycle:
+/// Created → Starting → Running → Stopping → Stopped → Disposed.
+/// </para>
+/// <para>
+/// This class is typically created and managed by <see cref="LambdaApplicationFactory{TEntryPoint}"/>
+/// rather than being instantiated directly. The server handles invocation queuing, response handling,
+/// timeout enforcement, and Lambda runtime API protocol compliance.
+/// </para>
+/// </remarks>
+public class LambdaTestServer : IAsyncDisposable
+{
+    /// <summary>
+    /// Task that represents the running Host application that has been captured.
+    /// </summary>
+    private readonly Task<Exception?> _entryPointCompletion;
+
+    /// <summary>
+    /// TCS used to signal the startup has completed
+    /// </summary>
+    private readonly TaskCompletionSource<InitResponse> _initCompletionTcs = new(
+        TaskCreationOptions.RunContinuationsAsynchronously
+    );
+
+    /// <summary>
+    /// JSON serializer options used to serialize/deserilize Lambda events and responses.
+    /// </summary>
+    private readonly JsonSerializerOptions _jsonSerializerOptions;
+
+    /// <summary>
+    /// Channel used to queue pending invocations in a FIFO manner.
+    /// </summary>
+    private readonly Channel<string> _pendingInvocationIds = Channel.CreateUnbounded<string>(
+        new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }
+    );
+
+    /// <summary>
+    /// Dictionary to track all invocations that have been sent to Lambda.
+    /// </summary>
+    private readonly ConcurrentDictionary<string, PendingInvocation> _pendingInvocations = new();
+
+    /// <summary>
+    /// Options used to configure how the server interacts with the Lambda.
+    /// </summary>
+    private readonly LambdaServerOptions _serverOptions;
+
+    /// <summary>
+    /// CTS used to signal shutdown of the server and cancellation of pending tasks.
+    /// </summary>
+    private readonly CancellationTokenSource _shutdownCts;
+
+    private readonly SemaphoreSlim _startSemaphore = new(1, 1);
+
+    /// <summary>
+    /// Channel used to by the Lambda to send events to the server.
+    /// </summary>
+    private readonly Channel<LambdaHttpTransaction> _transactionChannel =
+        Channel.CreateUnbounded<LambdaHttpTransaction>(
+            new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }
+        );
+
+    /// <summary>
+    /// Host application lifetime used to signal shutdown to the captioned Host.
+    /// </summary>
+    private IHostApplicationLifetime? _applicationLifetime;
+
+    /// <summary>
+    /// Indicates whether the server has been disposed.
+    /// </summary>
+    private bool _disposed;
+
+    /// <summary>
+    /// The captured Host instance.
+    /// </summary>
+    private IHost? _host;
+
+    /// <summary>
+    /// Task that is running the background processing loop to handle incoming requests from Lambda.
+    /// </summary>
+    private Task? _processingTask;
+
+    /// <summary>
+    /// Counter used to generate unique request IDs.
+    /// </summary>
+    private int _requestCounter;
+
+    internal LambdaTestServer(
+        Task<Exception?>? entryPointCompletion,
+        LambdaServerOptions serverOptions,
+        CancellationToken shutdownToken = default
+    )
+    {
+        ArgumentNullException.ThrowIfNull(entryPointCompletion);
+
+        _entryPointCompletion = entryPointCompletion;
+        _shutdownCts = CancellationTokenSource.CreateLinkedTokenSource(shutdownToken);
+        State = ServerState.Created;
+
+        _jsonSerializerOptions = DefaultLambdaJsonSerializerOptions.Create();
+        _serverOptions = serverOptions;
+    }
+
+    /// <summary>
+    /// Gets the <see cref="IServiceProvider"/> from the underlying <see cref="IHost"/> instance,
+    /// providing access to the dependency injection container for the Lambda application.
+    /// </summary>
+    /// <value>
+    /// The service provider from the captured host instance.
+    /// </value>
+    /// <remarks>
+    /// This property allows tests to resolve services from the Lambda application's DI container
+    /// for setup, assertion, or inspection purposes.
+    /// </remarks>
+    /// <exception cref="NullReferenceException">
+    /// Thrown if accessed before the host has been set via <see cref="SetHost"/>.
+    /// </exception>
+    public IServiceProvider Services => _host!.Services;
+
+    /// <summary>
+    /// Current state of the server used to enforce lifecycle rules.
+    /// </summary>
+    public ServerState State { get; private set; }
+
+    /// <summary>
+    /// Asynchronously releases all resources used by the <see cref="LambdaTestServer"/>.
+    /// </summary>
+    /// <returns>A <see cref="ValueTask"/> representing the asynchronous dispose operation.</returns>
+    /// <remarks>
+    /// <para>
+    /// This method performs the following cleanup operations:
+    /// </para>
+    /// <list type="number">
+    /// <item><description>Stops the server if it is currently running</description></item>
+    /// <item><description>Completes the transaction channel to prevent new requests</description></item>
+    /// <item><description>Cancels the shutdown token to signal background tasks</description></item>
+    /// <item><description>Disposes the underlying <see cref="IHost"/> instance</description></item>
+    /// <item><description>Transitions the server state to <see cref="ServerState.Disposed"/></description></item>
+    /// </list>
+    /// <para>
+    /// This method is safe to call multiple times. Subsequent calls after the first will return immediately
+    /// without performing any operations.
+    /// </para>
+    /// </remarks>
+    public async ValueTask DisposeAsync()
+    {
+        if (_disposed)
+            return;
+
+        // Complete both channels to prevent new items
+        _transactionChannel.Writer.TryComplete();
+        _pendingInvocationIds.Writer.TryComplete();
+
+        // Cancel the shutdown token
+        await _shutdownCts.CancelAsync();
+
+        if (State == ServerState.Running)
+            await StopAsync().ConfigureAwait(false);
+
+        // Dispose the CancellationTokenSource
+        _shutdownCts.Dispose();
+
+        State = ServerState.Disposed;
+        _disposed = true;
+
+        GC.SuppressFinalize(this);
+    }
+
+    internal void SetHost(IHost host)
+    {
+        ArgumentNullException.ThrowIfNull(host);
+        _host = host;
+    }
+
+    internal HttpMessageHandler CreateHandler() =>
+        new LambdaTestingHttpHandler(_transactionChannel, _shutdownCts.Token);
+
+    //      ┌──────────────────────────────────────────────────────────┐
+    //      │                        Public API                        │
+    //      └──────────────────────────────────────────────────────────┘
+
+    /// <summary>
+    /// Initializes and starts the Lambda test server, beginning the application host and preparing
+    /// it to accept invocations.
+    /// </summary>
+    /// <param name="cancellationToken">
+    /// A <see cref="CancellationToken"/> to observe while waiting for the server to start.
+    /// </param>
+    /// <returns>
+    /// A <see cref="Task{TResult}"/> that completes with an <see cref="InitResponse"/> indicating
+    /// whether initialization succeeded, failed with an error, or the host exited prematurely.
+    /// </returns>
+    /// <exception cref="InvalidOperationException">
+    /// Thrown if the server has already been started, if the host is not set, or if initialization
+    /// fails without an error or completion signal.
+    /// </exception>
+    /// <remarks>
+    /// <para>
+    /// This method can only be called once per server instance. The server must be in the
+    /// <see cref="ServerState.Created"/> state when this method is called.
+    /// </para>
+    /// <para>
+    /// The method starts the underlying <see cref="IHost"/>, begins background processing of
+    /// Lambda runtime HTTP transactions, and waits for the Lambda bootstrap to complete its
+    /// initialization phase by requesting the first invocation.
+    /// </para>
+    /// </remarks>
+    public async Task<InitResponse> StartAsync(CancellationToken cancellationToken = default)
+    {
+        var semaphoreAcquired = false;
+        try
+        {
+            await _startSemaphore.WaitAsync(cancellationToken);
+            semaphoreAcquired = true;
+
+            if (State == ServerState.Running)
+                return new InitResponse { InitStatus = InitStatus.InitAlreadyCompleted };
+            else if (State != ServerState.Created)
+                throw new InvalidOperationException(
+                    $"{nameof(LambdaTestServer)} has already been started and cannot be restarted."
+                );
+
+            if (_host is null)
+                throw new InvalidOperationException("Host is not set.");
+
+            using var cts = LinkedCts(cancellationToken);
+
+            State = ServerState.Starting;
+
+            _applicationLifetime = _host.Services.GetRequiredService<IHostApplicationLifetime>();
+
+            // Start the host
+            await _host.StartAsync(cts.Token);
+
+            // Start background processing
+            _processingTask = Task.Run(ProcessTransactionsAsync, cts.Token);
+
+            await TaskHelpers
+                .WhenAny(_processingTask, _entryPointCompletion, _initCompletionTcs.Task)
+                .UnwrapAndThrow("Exception(s) encountered while running StartAsync");
+
+            if (_entryPointCompletion.IsCompleted)
+            {
+                State = ServerState.Stopped;
+                return new InitResponse { InitStatus = InitStatus.HostExited };
+            }
+
+            if (_initCompletionTcs.Task.IsCompleted)
+            {
+                if (_initCompletionTcs.Task.Result.InitStatus == InitStatus.InitCompleted)
+                    State = ServerState.Running;
+                else
+                    await StopAsync(CancellationToken.None);
+
+                return _initCompletionTcs.Task.Result;
+            }
+
+            throw new InvalidOperationException(
+                $"{nameof(LambdaTestServer)} initialization failed with neither an error nor completion."
+            );
+        }
+        finally
+        {
+            if (semaphoreAcquired)
+                _startSemaphore.Release();
+        }
+    }
+
+    /// <summary>
+    /// Invokes the Lambda function with the specified event and waits for the response or error.
+    /// </summary>
+    /// <typeparam name="TResponse">The expected type of the Lambda function's response.</typeparam>
+    /// <typeparam name="TEvent">The type of the Lambda event to send to the function.</typeparam>
+    /// <param name="invokeEvent">The event object to pass to the Lambda function.</param>
+    /// <param name="noResponse">
+    /// Set to <see langword="true"/> when the handler does not return a response body (for example,
+    /// stream writers) to skip reading/deserializing the response.
+    /// </param>
+    /// <param name="traceId">
+    /// The AWS X-Ray trace ID to use for this invocation. If <see langword="null"/>, a new GUID will be generated and
+    /// surfaced on the `Lambda-Runtime-Trace-Id` header.
+    /// </param>
+    /// <param name="cancellationToken">
+    /// A <see cref="CancellationToken"/> to observe while waiting for the invocation to complete.
+    /// </param>
+    /// <returns>
+    /// A <see cref="Task{TResult}"/> that completes with an <see cref="InvocationResponse{TResponse}"/>
+    /// containing either the successful response or error information from the Lambda function.
+    /// </returns>
+    /// <exception cref="InvalidOperationException">
+    /// Thrown if the server is not in the <see cref="ServerState.Running"/> state, or if the
+    /// invocation cannot be enqueued.
+    /// </exception>
+    /// <exception cref="OperationCanceledException">
+    /// Thrown if the invocation times out based on <see cref="LambdaServerOptions.FunctionTimeout"/>
+    /// or if the <paramref name="cancellationToken"/> is cancelled.
+    /// </exception>
+    /// <remarks>
+    /// <para>
+    /// This method simulates the AWS Lambda invocation protocol by:
+    /// </para>
+    /// <list type="number">
+    /// <item><description>Generating a unique request ID</description></item>
+    /// <item><description>Creating an HTTP response with Lambda runtime headers</description></item>
+    /// <item><description>Queuing the invocation for the Lambda bootstrap to retrieve</description></item>
+    /// <item><description>Waiting for the Lambda function to respond or report an error</description></item>
+    /// <item><description>Deserializing the response or error information</description></item>
+    /// </list>
+    /// <para>
+    /// The invocation will timeout based on the <see cref="LambdaServerOptions.FunctionTimeout"/> setting,
+    /// which defaults to AWS Lambda's standard timeout behavior.
+    /// </para>
+    /// </remarks>
+    public async Task<InvocationResponse<TResponse>> InvokeAsync<TEvent, TResponse>(
+        TEvent? invokeEvent,
+        bool noResponse,
+        string? traceId = null,
+        CancellationToken cancellationToken = default
+    )
+    {
+        // inorder to allow
+        if (State != ServerState.Running)
+        {
+            var initResponse = await StartAsync(cancellationToken);
+            if (
+                initResponse.InitStatus
+                is not (InitStatus.InitCompleted or InitStatus.InitAlreadyCompleted)
+            )
+                throw new InvalidOperationException(
+                    $"{nameof(LambdaTestServer)} failed to initialize and returned a status of {initResponse.InitStatus.ToString()}."
+                );
+        }
+
+        if (State != ServerState.Running)
+            throw new InvalidOperationException(
+                $"{nameof(LambdaTestServer)} is in state {State} and cannot invoke an event. Expected state: Running."
+            );
+
+        using var cts = LinkedCtsWithInvocationDeadline(cancellationToken);
+
+        traceId ??= Guid.NewGuid().ToString();
+
+        // Generate unique request ID
+        var requestId = GetRequestId();
+
+        // Create the event response with Lambda headers
+        var eventResponse = CreateEventResponse(invokeEvent, requestId, traceId);
+        var deadlineUtc = DateTimeOffset.UtcNow.Add(_serverOptions.FunctionTimeout);
+
+        var pending = PendingInvocation.Create(requestId, eventResponse, deadlineUtc);
+
+        _pendingInvocations.AddRequired(requestId, pending);
+
+        if (!_pendingInvocationIds.Writer.TryWrite(requestId))
+            throw new InvalidOperationException("Failed to enqueue pending invocation");
+
+        var completion = await pending.ResponseTcs.Task.WaitAsync(cts.Token);
+
+        var responseMessage = completion.Request;
+        var wasSuccess = completion.RequestType == RequestType.PostResponse;
+
+        var response =
+            wasSuccess && !noResponse
+                ? await (
+                    responseMessage.Content?.ReadFromJsonAsync<TResponse>(
+                        _jsonSerializerOptions,
+                        cts.Token
+                    ) ?? Task.FromResult<TResponse?>(default)
+                )
+                : default;
+
+        var error = !wasSuccess
+            ? await (
+                responseMessage.Content?.ReadFromJsonAsync<ErrorResponse>(
+                    _jsonSerializerOptions,
+                    cts.Token
+                ) ?? Task.FromResult<ErrorResponse?>(null)
+            )
+            : null;
+
+        _pendingInvocations.TryRemove(requestId, out _);
+
+        return new InvocationResponse<TResponse>
+        {
+            WasSuccess = wasSuccess,
+            Response = response,
+            Error = error,
+        };
+    }
+
+    /// <summary>
+    /// Gracefully stops the running test server, shutting down the Lambda application host and
+    /// completing all background processing.
+    /// </summary>
+    /// <param name="cancellationToken">
+    /// A <see cref="CancellationToken"/> to observe while waiting for the server to stop.
+    /// </param>
+    /// <returns>A <see cref="Task"/> representing the asynchronous stop operation.</returns>
+    /// <exception cref="InvalidOperationException">
+    /// Thrown if the server is not currently in the <see cref="ServerState.Running"/> state.
+    /// </exception>
+    /// <remarks>
+    /// <para>
+    /// This method performs the following shutdown sequence:
+    /// </para>
+    /// <list type="number">
+    /// <item><description>Transitions the server state to <see cref="ServerState.Stopping"/></description></item>
+    /// <item><description>Cancels the internal shutdown token to signal background tasks</description></item>
+    /// <item><description>Stops the application host via <see cref="IHostApplicationLifetime"/></description></item>
+    /// <item><description>Waits for the entry point and processing tasks to complete</description></item>
+    /// <item><description>Transitions the server state to <see cref="ServerState.Stopped"/></description></item>
+    /// </list>
+    /// <para>
+    /// After stopping, the server cannot be restarted. A new <see cref="LambdaTestServer"/> instance
+    /// must be created for subsequent test runs.
+    /// </para>
+    /// </remarks>
+    public async Task StopAsync(CancellationToken cancellationToken = default)
+    {
+        if (State is <= ServerState.Created or >= ServerState.Stopping)
+            throw new InvalidOperationException(
+                $"{nameof(LambdaTestServer)} cannot be stopped in state {State}."
+            );
+
+        State = ServerState.Stopping;
+
+        await _shutdownCts.CancelAsync();
+
+        _applicationLifetime?.StopApplication();
+
+        await TaskHelpers
+            .WhenAll(_entryPointCompletion, _processingTask ?? Task.CompletedTask)
+            .UnwrapAndThrow("Exception(s) encountered while running StopAsync")
+            .WaitAsync(cancellationToken);
+
+        State = ServerState.Stopped;
+    }
+
+    //      ┌──────────────────────────────────────────────────────────┐
+    //      │                Internal TestServer Logic                 │
+    //      └──────────────────────────────────────────────────────────┘
+
+    private async Task ProcessTransactionsAsync()
+    {
+        try
+        {
+            await foreach (
+                var transaction in _transactionChannel.Reader.ReadAllAsync(_shutdownCts.Token)
+            )
+            {
+                if (
+                    !LambdaRuntimeRouteManager.TryMatch(
+                        transaction.Request,
+                        out var requestType,
+                        out var routeValues
+                    )
+                )
+                    throw new InvalidOperationException(
+                        $"Unexpected request received from the Lambda HTTP handler: {transaction.Request.Method} {transaction.Request.RequestUri}"
+                    );
+
+                switch (requestType.Value)
+                {
+                    case RequestType.GetNextInvocation:
+                        await HandleGetNextInvocationAsync(transaction);
+                        break;
+
+                    case RequestType.PostResponse:
+                        await HandlePostResponseAsync(transaction, routeValues);
+                        break;
+
+                    case RequestType.PostError:
+                        await HandlePostErrorAsync(transaction, routeValues);
+                        break;
+
+                    case RequestType.PostInitError:
+                        await HandlePostInitErrorAsync(transaction);
+                        break;
+
+                    default:
+                        throw new InvalidOperationException(
+                            $"Unexpected request type {requestType} for {transaction.Request.RequestUri}"
+                        );
+                }
+            }
+        }
+        catch (OperationCanceledException) when (_shutdownCts.IsCancellationRequested)
+        {
+            // Expected when task is canceled
+        }
+    }
+
+    private async Task HandleGetNextInvocationAsync(LambdaHttpTransaction transaction)
+    {
+        if (State == ServerState.Starting)
+            _initCompletionTcs.SetResult(
+                new InitResponse { InitStatus = InitStatus.InitCompleted }
+            );
+
+        if (await _pendingInvocationIds.Reader.WaitToReadAsync(_shutdownCts.Token))
+        {
+            var requestId = await _pendingInvocationIds.Reader.ReadAsync(_shutdownCts.Token);
+            _pendingInvocations.GetRequired(requestId, out var pendingInvocation);
+            transaction.ResponseTcs.SetResult(pendingInvocation.EventResponse);
+        }
+    }
+
+    private async Task HandlePostResponseAsync(
+        LambdaHttpTransaction transaction,
+        RouteValueDictionary routeValues
+    )
+    {
+        _pendingInvocations.GetRequired(routeValues["requestId"]?.ToString(), out var pending);
+
+        // Acknowledge to Bootstrap
+        transaction.Respond(CreateSuccessResponse());
+
+        pending.ResponseTcs.SetResult(
+            await CreateCompletionAsync(RequestType.PostResponse, transaction.Request)
+        );
+    }
+
+    private async Task HandlePostErrorAsync(
+        LambdaHttpTransaction transaction,
+        RouteValueDictionary routeValues
+    )
+    {
+        _pendingInvocations.GetRequired(routeValues["requestId"]?.ToString(), out var pending);
+
+        // Acknowledge to Bootstrap
+        transaction.Respond(CreateSuccessResponse());
+
+        pending.ResponseTcs.SetResult(
+            await CreateCompletionAsync(RequestType.PostError, transaction.Request)
+        );
+    }
+
+    private async Task HandlePostInitErrorAsync(LambdaHttpTransaction transaction)
+    {
+        if (State == ServerState.Starting)
+        {
+            _initCompletionTcs.SetResult(
+                new InitResponse
+                {
+                    Error = await (
+                        transaction.Request.Content?.ReadFromJsonAsync<ErrorResponse>(
+                            _jsonSerializerOptions
+                        ) ?? Task.FromResult<ErrorResponse?>(null)
+                    ),
+                    InitStatus = InitStatus.InitError,
+                }
+            );
+            return;
+        }
+
+        throw new InvalidOperationException(
+            $"{nameof(LambdaTestServer)} is already started and as such an initialization error cannot be reported."
+        );
+    }
+
+    private HttpResponseMessage CreateEventResponse<TEvent>(
+        TEvent? invokeEvent,
+        string requestId,
+        string traceId
+    )
+    {
+        var response = new HttpResponseMessage(HttpStatusCode.OK)
+        {
+            Version = Version.Parse("1.1"),
+        };
+
+        if (invokeEvent is not null)
+            response.Content = new StringContent(
+                JsonSerializer.Serialize(invokeEvent, _jsonSerializerOptions),
+                Encoding.UTF8,
+                "application/json"
+            );
+
+        // Add standard HTTP headers
+        response.Headers.Date = new DateTimeOffset(DateTime.UtcNow, TimeSpan.Zero);
+        response.Headers.TransferEncodingChunked = true;
+
+        // Add custom Lambda runtime headers
+        var deadlineMs = DateTimeOffset
+            .UtcNow.Add(_serverOptions.FunctionTimeout)
+            .ToUnixTimeMilliseconds();
+        response.Headers.Add("Lambda-Runtime-Deadline-Ms", deadlineMs.ToString());
+        response.Headers.Add("Lambda-Runtime-Aws-Request-Id", requestId);
+        response.Headers.Add("Lambda-Runtime-Trace-Id", traceId);
+        response.Headers.Add("Lambda-Runtime-Invoked-Function-Arn", _serverOptions.FunctionArn);
+
+        // Add any additional custom headers
+        foreach (var header in _serverOptions.AdditionalHeaders)
+            response.Headers.Add(header.Key, header.Value);
+
+        return response;
+    }
+
+    private string GetRequestId() =>
+        Interlocked.Increment(ref _requestCounter).ToString().PadLeft(12, '0');
+
+    private static async Task<InvocationCompletion> CreateCompletionAsync(
+        RequestType requestType,
+        HttpRequestMessage sourceRequest
+    )
+    {
+        var clonedRequest = new HttpRequestMessage(sourceRequest.Method, sourceRequest.RequestUri)
+        {
+            Version = sourceRequest.Version,
+            VersionPolicy = sourceRequest.VersionPolicy,
+        };
+
+        foreach (var header in sourceRequest.Headers)
+            clonedRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);
+
+        foreach (var option in sourceRequest.Options)
+            clonedRequest.Options.TryAdd(option.Key, option.Value);
+
+        if (sourceRequest.Content != null)
+        {
+            var contentBytes = await sourceRequest.Content.ReadAsByteArrayAsync();
+            var clonedContent = new ByteArrayContent(contentBytes);
+
+            foreach (var header in sourceRequest.Content.Headers)
+                clonedContent.Headers.TryAddWithoutValidation(header.Key, header.Value);
+
+            clonedRequest.Content = clonedContent;
+        }
+
+        return new InvocationCompletion { Request = clonedRequest, RequestType = requestType };
+    }
+
+    private static HttpResponseMessage CreateSuccessResponse() =>
+        new(HttpStatusCode.Accepted)
+        {
+            Content = new StringContent(
+                """
+                {"status":"success"}
+                """,
+                Encoding.UTF8,
+                "application/json"
+            ),
+            Version = Version.Parse("1.1"),
+        };
+
+    private CancellationTokenSource LinkedCtsWithInvocationDeadline(
+        CancellationToken cancellationTokens
+    )
+    {
+        var cts = CancellationTokenSource.CreateLinkedTokenSource(
+            cancellationTokens,
+            _shutdownCts.Token
+        );
+        cts.CancelAfter(_serverOptions.FunctionTimeout);
+
+        return cts;
+    }
+
+    private CancellationTokenSource LinkedCts(CancellationToken cancellationTokens) =>
+        CancellationTokenSource.CreateLinkedTokenSource(cancellationTokens, _shutdownCts.Token);
+}
diff --git a/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs b/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs
new file mode 100644
index 00000000..37e31c26
--- /dev/null
+++ b/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs
@@ -0,0 +1,124 @@
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// Provides convenience extension methods for <see cref="LambdaTestServer"/> to simplify common invocation patterns.
+/// </summary>
+/// <remarks>
+/// <para>
+/// These extension methods wrap the core <see cref="LambdaTestServer.InvokeAsync{TEvent, TResponse}"/> method,
+/// providing simplified overloads for common scenarios such as invocations without events or without response bodies.
+/// </para>
+/// </remarks>
+public static class LambdaTestServerExtensions
+{
+    extension(LambdaTestServer server)
+    {
+        /// <summary>
+        /// Invokes the Lambda function with the specified event and waits for the response.
+        /// </summary>
+        /// <typeparam name="TEvent">The type of the Lambda event to send to the function.</typeparam>
+        /// <typeparam name="TResponse">The expected type of the Lambda function's response.</typeparam>
+        /// <param name="invokeEvent">The event object to pass to the Lambda function.</param>
+        /// <param name="cancellationToken">
+        /// A <see cref="CancellationToken"/> to observe while waiting for the invocation to complete.
+        /// </param>
+        /// <returns>
+        /// A <see cref="Task{TResult}"/> that completes with an <see cref="InvocationResponse{TResponse}"/>
+        /// containing either the successful response or error information from the Lambda function.
+        /// </returns>
+        /// <exception cref="InvalidOperationException">
+        /// Thrown if the server is not in a valid state to accept invocations.
+        /// </exception>
+        /// <exception cref="OperationCanceledException">
+        /// Thrown if the invocation times out or the <paramref name="cancellationToken"/> is cancelled.
+        /// </exception>
+        /// <remarks>
+        /// This is a convenience method that invokes the Lambda function with an event and expects a response body.
+        /// It is equivalent to calling <see cref="LambdaTestServer.InvokeAsync{TEvent, TResponse}"/> with
+        /// <c>noResponse</c> set to <see langword="false"/>.
+        /// </remarks>
+        public Task<InvocationResponse<TResponse>> InvokeAsync<TEvent, TResponse>(
+            TEvent invokeEvent,
+            CancellationToken cancellationToken = default
+        ) =>
+            server.InvokeAsync<TEvent, TResponse>(
+                invokeEvent,
+                false,
+                cancellationToken: cancellationToken
+            );
+
+        /// <summary>
+        /// Invokes the Lambda function without an event and waits for the response.
+        /// </summary>
+        /// <typeparam name="TResponse">The expected type of the Lambda function's response.</typeparam>
+        /// <param name="cancellationToken">
+        /// A <see cref="CancellationToken"/> to observe while waiting for the invocation to complete.
+        /// </param>
+        /// <returns>
+        /// A <see cref="Task{TResult}"/> that completes with an <see cref="InvocationResponse{TResponse}"/>
+        /// containing either the successful response or error information from the Lambda function.
+        /// </returns>
+        /// <exception cref="InvalidOperationException">
+        /// Thrown if the server is not in a valid state to accept invocations.
+        /// </exception>
+        /// <exception cref="OperationCanceledException">
+        /// Thrown if the invocation times out or the <paramref name="cancellationToken"/> is cancelled.
+        /// </exception>
+        /// <remarks>
+        /// <para>
+        /// This is a convenience method for invoking Lambda functions that do not require an event payload,
+        /// such as handlers that perform scheduled tasks or generate data without input.
+        /// </para>
+        /// <para>
+        /// It is equivalent to calling <see cref="LambdaTestServer.InvokeAsync{TEvent, TResponse}"/> with
+        /// a <see langword="null"/> event and <c>noResponse</c> set to <see langword="false"/>.
+        /// </para>
+        /// </remarks>
+        public Task<InvocationResponse<TResponse>> InvokeNoEventAsync<TResponse>(
+            CancellationToken cancellationToken = default
+        ) =>
+            server.InvokeAsync<object, TResponse>(
+                null,
+                false,
+                cancellationToken: cancellationToken
+            );
+
+        /// <summary>
+        /// Invokes the Lambda function with the specified event but does not expect or deserialize a response body.
+        /// </summary>
+        /// <typeparam name="TEvent">The type of the Lambda event to send to the function.</typeparam>
+        /// <param name="invokeEvent">The event object to pass to the Lambda function.</param>
+        /// <param name="cancellationToken">
+        /// A <see cref="CancellationToken"/> to observe while waiting for the invocation to complete.
+        /// </param>
+        /// <returns>
+        /// A <see cref="Task{TResult}"/> that completes with an <see cref="InvocationResponse"/>
+        /// containing either success status or error information from the Lambda function.
+        /// </returns>
+        /// <exception cref="InvalidOperationException">
+        /// Thrown if the server is not in a valid state to accept invocations.
+        /// </exception>
+        /// <exception cref="OperationCanceledException">
+        /// Thrown if the invocation times out or the <paramref name="cancellationToken"/> is cancelled.
+        /// </exception>
+        /// <remarks>
+        /// <para>
+        /// This is a convenience method for invoking Lambda functions that do not return a response body,
+        /// such as handlers that write directly to streams or perform side effects without returning data.
+        /// </para>
+        /// <para>
+        /// It is equivalent to calling <see cref="LambdaTestServer.InvokeAsync{TEvent, TResponse}"/> with
+        /// <c>noResponse</c> set to <see langword="true"/>.
+        /// </para>
+        /// </remarks>
+        public async Task<InvocationResponse> InvokeNoResponseAsync<TEvent>(
+            TEvent invokeEvent,
+            CancellationToken cancellationToken = default
+        ) =>
+            await server.InvokeAsync<TEvent, object>(
+                invokeEvent,
+                true,
+                cancellationToken: cancellationToken
+            );
+    }
+}
diff --git a/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj b/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj
new file mode 100644
index 00000000..90a3e785
--- /dev/null
+++ b/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj
@@ -0,0 +1,27 @@
+﻿<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
+    <LangVersion>preview</LangVersion>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
+    <IncludeBuildOutput>true</IncludeBuildOutput>
+    <GenerateDocumentationFile>true</GenerateDocumentationFile>
+    <!-- Package Metadata -->
+    <PackageId>MinimalLambda.Testing</PackageId>
+    <Description>In-memory testing harness for MinimalLambda</Description>
+    <PackageReadmeFile>README.md</PackageReadmeFile>
+  </PropertyGroup>
+  <ItemGroup>
+    <None Include="README.md" Pack="true" PackagePath="\" />
+  </ItemGroup>
+  <ItemGroup>
+    <ProjectReference Include="..\MinimalLambda.Abstractions\MinimalLambda.Abstractions.csproj" />
+    <ProjectReference Include="..\MinimalLambda\MinimalLambda.csproj" />
+  </ItemGroup>
+  <ItemGroup>
+    <PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" />
+    <PackageReference Include="Microsoft.AspNetCore.Routing" />
+    <PackageReference Include="Microsoft.Extensions.DependencyModel" />
+  </ItemGroup>
+</Project>
diff --git a/src/MinimalLambda.Testing/Models/ErrorResponse.cs b/src/MinimalLambda.Testing/Models/ErrorResponse.cs
new file mode 100644
index 00000000..dd0c433d
--- /dev/null
+++ b/src/MinimalLambda.Testing/Models/ErrorResponse.cs
@@ -0,0 +1,69 @@
+using System.Text.Json.Serialization;
+
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// Represents an error response with type, message, stack trace, and optional cause.
+/// </summary>
+public class ErrorResponse
+{
+    /// <summary>
+    /// The underlying cause of this error, if any.
+    /// </summary>
+    [JsonPropertyName("cause")]
+    public ErrorCause? Cause { get; set; }
+
+    /// <summary>
+    /// The underlying causes of this error, if any.
+    /// </summary>
+    [JsonPropertyName("causes")]
+    public List<ErrorCause>? Causes { get; set; }
+
+    /// <summary>
+    /// The error message describing what went wrong.
+    /// </summary>
+    [JsonPropertyName("errorMessage")]
+    public required string ErrorMessage { get; set; }
+
+    /// <summary>
+    /// The type of error that occurred.
+    /// </summary>
+    [JsonPropertyName("errorType")]
+    public required string ErrorType { get; set; }
+
+    /// <summary>
+    /// The stack trace showing where the error occurred.
+    /// </summary>
+    [JsonPropertyName("stackTrace")]
+    public List<string> StackTrace { get; set; } = [];
+
+    /// <summary>
+    /// Represents the cause of an error, which can have its own nested cause.
+    /// </summary>
+    public class ErrorCause
+    {
+        /// <summary>
+        /// The underlying cause of this error, if any.
+        /// </summary>
+        [JsonPropertyName("cause")]
+        public ErrorCause? Cause { get; set; }
+
+        /// <summary>
+        /// The error message describing what went wrong.
+        /// </summary>
+        [JsonPropertyName("errorMessage")]
+        public required string ErrorMessage { get; set; }
+
+        /// <summary>
+        /// The type of error that occurred.
+        /// </summary>
+        [JsonPropertyName("errorType")]
+        public required string ErrorType { get; set; }
+
+        /// <summary>
+        /// The stack trace showing where the error occurred.
+        /// </summary>
+        [JsonPropertyName("stackTrace")]
+        public List<string> StackTrace { get; set; } = [];
+    }
+}
diff --git a/src/MinimalLambda.Testing/Models/InitResponse.cs b/src/MinimalLambda.Testing/Models/InitResponse.cs
new file mode 100644
index 00000000..823f9947
--- /dev/null
+++ b/src/MinimalLambda.Testing/Models/InitResponse.cs
@@ -0,0 +1,17 @@
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// Represents the result of a Lambda function initialization attempt.
+/// </summary>
+public class InitResponse
+{
+    /// <summary>
+    /// Gets the error information if initialization failed, or null if initialization succeeded.
+    /// </summary>
+    public ErrorResponse? Error { get; internal init; }
+
+    /// <summary>
+    /// Gets the status of the initialization attempt.
+    /// </summary>
+    public InitStatus InitStatus { get; internal init; }
+}
diff --git a/src/MinimalLambda.Testing/Models/InitStatus.cs b/src/MinimalLambda.Testing/Models/InitStatus.cs
new file mode 100644
index 00000000..ae8f0f92
--- /dev/null
+++ b/src/MinimalLambda.Testing/Models/InitStatus.cs
@@ -0,0 +1,27 @@
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// An enumeration of possible statuses for Lambda initialization.
+/// </summary>
+public enum InitStatus
+{
+    /// <summary>
+    /// Initialization of the Lambda completed successfully.
+    /// </summary>
+    InitCompleted,
+
+    /// <summary>
+    /// Initialization of the Lambda failed, and the Lambda returned an error.
+    /// </summary>
+    InitError,
+
+    /// <summary>
+    /// Initialization of the Lambda failed, and the Host process exited.
+    /// </summary>
+    HostExited,
+
+    /// <summary>
+    /// Initialization has already been completed and cannot be repeated.
+    /// </summary>
+    InitAlreadyCompleted,
+}
diff --git a/src/MinimalLambda.Testing/Models/InvocationCompletion.cs b/src/MinimalLambda.Testing/Models/InvocationCompletion.cs
new file mode 100644
index 00000000..0737b98a
--- /dev/null
+++ b/src/MinimalLambda.Testing/Models/InvocationCompletion.cs
@@ -0,0 +1,10 @@
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// Represents the completion of a Lambda invocation with metadata about the outcome.
+/// </summary>
+internal class InvocationCompletion
+{
+    internal required HttpRequestMessage Request { get; init; }
+    internal required RequestType RequestType { get; init; }
+}
diff --git a/src/MinimalLambda.Testing/Models/InvocationResponse.cs b/src/MinimalLambda.Testing/Models/InvocationResponse.cs
new file mode 100644
index 00000000..354e2802
--- /dev/null
+++ b/src/MinimalLambda.Testing/Models/InvocationResponse.cs
@@ -0,0 +1,48 @@
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// Represents the base result of a Lambda function invocation, containing success status and
+/// error information.
+/// </summary>
+/// <remarks>
+/// <para>
+/// This class serves as the non-generic base for <see cref="InvocationResponse{TResponse}"/>,
+/// providing common properties for all invocation results regardless of the response type.
+/// It contains the <see cref="WasSuccess"/> flag and <see cref="Error"/> information that
+/// are shared across all invocation responses.
+/// </para>
+/// <para>
+/// For invocations that return typed response data, use the generic
+/// <see cref="InvocationResponse{TResponse}"/> class instead.
+/// </para>
+/// </remarks>
+public class InvocationResponse
+{
+    /// <summary>
+    /// Gets the error information if the Lambda function invocation failed, or <see langword="null"/>
+    /// if the invocation succeeded.
+    /// </summary>
+    /// <value>
+    /// An <see cref="ErrorResponse"/> containing error details, or <see langword="null"/> for successful invocations.
+    /// </value>
+    /// <remarks>
+    /// This property is populated when the Lambda function reports an error via the runtime API's
+    /// error endpoint, or when the invocation times out or encounters other failures.
+    /// </remarks>
+    public ErrorResponse? Error { get; internal init; }
+
+    /// <summary>
+    /// Gets a value indicating whether the Lambda function invocation completed successfully.
+    /// </summary>
+    /// <value>
+    /// <see langword="true"/> if the invocation succeeded and response data is available (in
+    /// <see cref="InvocationResponse{TResponse}.Response"/> for generic invocations);
+    /// <see langword="false"/> if the invocation failed and <see cref="Error"/> contains error information.
+    /// </value>
+    /// <remarks>
+    /// Use this property to determine whether the invocation succeeded or failed, which indicates
+    /// whether response data or <see cref="Error"/> information contains meaningful data for the
+    /// invocation result.
+    /// </remarks>
+    public bool WasSuccess { get; internal init; }
+}
diff --git a/src/MinimalLambda.Testing/Models/InvocationResponseT.cs b/src/MinimalLambda.Testing/Models/InvocationResponseT.cs
new file mode 100644
index 00000000..e4d1e58b
--- /dev/null
+++ b/src/MinimalLambda.Testing/Models/InvocationResponseT.cs
@@ -0,0 +1,31 @@
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// Represents the result of a Lambda function invocation with a typed response, containing either
+/// a successful response or error information.
+/// </summary>
+/// <typeparam name="TResponse">The expected type of the successful Lambda response.</typeparam>
+/// <remarks>
+/// <para>
+/// This generic class extends <see cref="InvocationResponse"/> to provide strongly-typed access
+/// to the Lambda function's response data. Use the <see cref="InvocationResponse.WasSuccess"/>
+/// property to determine whether the invocation succeeded or failed. If successful,
+/// <see cref="Response"/> will contain the deserialized response data. If failed,
+/// <see cref="InvocationResponse.Error"/> will contain details about the error.
+/// </para>
+/// </remarks>
+public class InvocationResponse<TResponse> : InvocationResponse
+{
+    /// <summary>
+    /// Gets the Lambda function's response data if the invocation succeeded, or the default value
+    /// of <typeparamref name="TResponse"/> if the invocation failed.
+    /// </summary>
+    /// <value>
+    /// The deserialized response from the Lambda function, or <see langword="null"/> for failed invocations.
+    /// </value>
+    /// <remarks>
+    /// This property is populated when the Lambda function successfully completes and returns a response
+    /// via the runtime API's response endpoint.
+    /// </remarks>
+    public TResponse? Response { get; internal init; }
+}
diff --git a/src/MinimalLambda.Testing/Models/LambdaHttpTransaction.cs b/src/MinimalLambda.Testing/Models/LambdaHttpTransaction.cs
new file mode 100644
index 00000000..cb3a6015
--- /dev/null
+++ b/src/MinimalLambda.Testing/Models/LambdaHttpTransaction.cs
@@ -0,0 +1,41 @@
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// Represents a single HTTP transaction from the Lambda Bootstrap.
+/// Bundles the request with its response completion mechanism for automatic correlation.
+/// </summary>
+internal class LambdaHttpTransaction
+{
+    /// <summary>
+    /// The HTTP request from Lambda Bootstrap.
+    /// </summary>
+    internal required HttpRequestMessage Request { get; init; }
+
+    /// <summary>
+    /// Task completion source for the HTTP response.
+    /// Completing this sends the response back to Bootstrap.
+    /// </summary>
+    internal required TaskCompletionSource<HttpResponseMessage> ResponseTcs { get; init; }
+
+    /// <summary>
+    /// Creates a new transaction with RunContinuationsAsynchronously to prevent deadlocks.
+    /// </summary>
+    internal static LambdaHttpTransaction Create(HttpRequestMessage request) =>
+        new()
+        {
+            Request = request,
+            ResponseTcs = new TaskCompletionSource<HttpResponseMessage>(
+                TaskCreationOptions.RunContinuationsAsynchronously
+            ),
+        };
+
+    /// <summary>
+    /// Completes the transaction with a successful HTTP response.
+    /// </summary>
+    internal bool Respond(HttpResponseMessage response) => ResponseTcs.TrySetResult(response);
+
+    /// <summary>
+    /// Completes the transaction with cancellation.
+    /// </summary>
+    internal bool Cancel() => ResponseTcs.TrySetCanceled();
+}
diff --git a/src/MinimalLambda.Testing/Models/PendingInvocation.cs b/src/MinimalLambda.Testing/Models/PendingInvocation.cs
new file mode 100644
index 00000000..12eb63b9
--- /dev/null
+++ b/src/MinimalLambda.Testing/Models/PendingInvocation.cs
@@ -0,0 +1,52 @@
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// Represents a pending Lambda invocation waiting for Bootstrap to process and respond.
+/// </summary>
+internal class PendingInvocation
+{
+    /// <summary>
+    /// Timestamp when this invocation was created (for timeout tracking).
+    /// </summary>
+    internal DateTime CreatedAt { get; init; } = DateTime.UtcNow;
+
+    /// <summary>
+    /// Absolute time when this invocation should be considered expired.
+    /// </summary>
+    internal required DateTimeOffset DeadlineUtc { get; init; }
+
+    /// <summary>
+    /// The HTTP response containing the serialized event payload and Lambda headers
+    /// to send to Bootstrap when it polls for the next invocation.
+    /// </summary>
+    internal required HttpResponseMessage EventResponse { get; init; }
+
+    /// <summary>
+    /// Unique request ID for this invocation (will be in Lambda-Runtime-Aws-Request-Id header).
+    /// </summary>
+    internal required string RequestId { get; init; }
+
+    /// <summary>
+    /// Task completion source for the invocation result.
+    /// Completed when Bootstrap posts response or error with the HTTP request containing the result payload.
+    /// </summary>
+    internal required TaskCompletionSource<InvocationCompletion> ResponseTcs { get; init; }
+
+    /// <summary>
+    /// Creates a pending invocation with proper TCS configuration.
+    /// </summary>
+    internal static PendingInvocation Create(
+        string requestId,
+        HttpResponseMessage eventResponse,
+        DateTimeOffset deadlineUtc
+    ) =>
+        new()
+        {
+            RequestId = requestId,
+            EventResponse = eventResponse,
+            ResponseTcs = new TaskCompletionSource<InvocationCompletion>(
+                TaskCreationOptions.RunContinuationsAsynchronously
+            ),
+            DeadlineUtc = deadlineUtc,
+        };
+}
diff --git a/src/MinimalLambda.Testing/Models/RequestType.cs b/src/MinimalLambda.Testing/Models/RequestType.cs
new file mode 100644
index 00000000..406ba336
--- /dev/null
+++ b/src/MinimalLambda.Testing/Models/RequestType.cs
@@ -0,0 +1,9 @@
+namespace MinimalLambda.Testing;
+
+internal enum RequestType
+{
+    GetNextInvocation,
+    PostResponse,
+    PostError,
+    PostInitError,
+}
diff --git a/src/MinimalLambda.Testing/Models/ServerState.cs b/src/MinimalLambda.Testing/Models/ServerState.cs
new file mode 100644
index 00000000..164c3803
--- /dev/null
+++ b/src/MinimalLambda.Testing/Models/ServerState.cs
@@ -0,0 +1,37 @@
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// Represents the lifecycle state of a LambdaTestServer.
+/// </summary>
+public enum ServerState
+{
+    /// <summary>
+    /// TestServer created but not started.
+    /// </summary>
+    Created,
+
+    /// <summary>
+    /// TestServer is starting (building host).
+    /// </summary>
+    Starting,
+
+    /// <summary>
+    /// TestServer is running and accepting invocations.
+    /// </summary>
+    Running,
+
+    /// <summary>
+    /// TestServer is stopping.
+    /// </summary>
+    Stopping,
+
+    /// <summary>
+    /// TestServer has stopped cleanly.
+    /// </summary>
+    Stopped,
+
+    /// <summary>
+    /// TestServer has been disposed.
+    /// </summary>
+    Disposed,
+}
diff --git a/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs b/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs
new file mode 100644
index 00000000..7c977de0
--- /dev/null
+++ b/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs
@@ -0,0 +1,28 @@
+namespace MinimalLambda.Testing;
+
+/// <summary>
+/// Configuration options for the Lambda test client.
+/// </summary>
+public class LambdaServerOptions
+{
+    /// <summary>
+    /// Gets or sets additional custom headers to include in the Lambda invocation response.
+    /// Use this to add any additional headers beyond the standard Lambda runtime headers.
+    /// </summary>
+    public Dictionary<string, string> AdditionalHeaders { get; set; } = new();
+
+    /// <summary>
+    /// Gets or sets the ARN of the Lambda function being invoked.
+    /// Maps to the <c>Lambda-Runtime-Invoked-Function-Arn</c> header.
+    /// </summary>
+    public string FunctionArn { get; set; } =
+        "arn:aws:lambda:us-west-2:123412341234:function:Function";
+
+    /// <summary>
+    /// Gets or sets the Lambda function timeout duration.
+    /// Maps to the <c>Lambda-Runtime-Deadline-Ms</c> header as Unix epoch milliseconds.
+    /// This determines when the Lambda function will timeout, calculated as the current time plus this duration.
+    /// Defaults to 15 minutes.
+    /// </summary>
+    public TimeSpan FunctionTimeout { get; set; } = TimeSpan.FromMinutes(15);
+}
diff --git a/src/MinimalLambda.Testing/Properties/AssemblyInfo.cs b/src/MinimalLambda.Testing/Properties/AssemblyInfo.cs
new file mode 100644
index 00000000..e3d42db9
--- /dev/null
+++ b/src/MinimalLambda.Testing/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("MinimalLambda.Testing.UnitTests")]
diff --git a/src/MinimalLambda.Testing/README.md b/src/MinimalLambda.Testing/README.md
new file mode 100644
index 00000000..dc0a1231
--- /dev/null
+++ b/src/MinimalLambda.Testing/README.md
@@ -0,0 +1,118 @@
+# MinimalLambda.Testing
+
+In-memory Lambda runtime for end-to-end and integration testing with the MinimalLambda framework.
+
+> 📚 **[View Full Documentation](https://j-d-ha.github.io/minimal-lambda/)**
+
+## Overview
+
+MinimalLambda.Testing lets you run Lambda functions entirely in memory, exercising the same runtime
+API that AWS provides without deploying or opening network ports. It follows the familiar ASP.NET
+Core `WebApplicationFactory` pattern: reusing your real `Program` entry point via
+`LambdaApplicationFactory<TEntryPoint>` and driving it through a `LambdaTestServer` that mimics the
+Lambda Runtime API (init, invocation, and shutdown).
+
+Use it to:
+
+- **Boot real apps**: Spin up your Lambda entry point with `LambdaApplicationFactory` just like
+  ASP.NET's `WebApplicationFactory`
+- **Simulate Runtime API**: `LambdaTestServer` feeds events and receives responses/errors over the
+  Lambda Runtime HTTP contract—no mocks or stubs
+- **Typed Invocations**: `InvokeAsync<TEvent, TResponse>` sends strongly typed events and returns
+  typed responses, including structured error details
+- **Lifecycle Coverage**: Exercise `OnInit` and `OnShutdown` hooks and verify cold-start logic
+- **Host Customization**: Override configuration and services for tests with `WithHostBuilder`
+
+## Installation
+
+This package extends [MinimalLambda](../MinimalLambda/README.md); install both:
+
+```bash
+dotnet add package MinimalLambda
+dotnet add package MinimalLambda.Testing
+```
+
+Ensure your project uses C# 11 or later:
+
+```xml
+
+<PropertyGroup>
+  <LangVersion>11</LangVersion>
+  <!-- or <LangVersion>latest</LangVersion> -->
+</PropertyGroup>
+```
+
+## Quick Start
+
+Write an end-to-end test that drives your Lambda through the in-memory runtime:
+
+```csharp
+using MinimalLambda.Testing;
+using Xunit;
+
+public class LambdaTests
+{
+    [Fact]
+    public async Task HelloWorldHandler_ReturnsGreeting()
+    {
+        await using var factory = new LambdaApplicationFactory<Program>();
+
+        await factory.TestServer.StartAsync();
+
+        var response = await factory.TestServer.InvokeAsync<string, string>("World");
+
+        Assert.True(response.WasSuccess);
+        Assert.Equal("Hello World!", response.Response);
+    }
+}
+```
+
+Customize the host configuration for a specific test:
+
+```csharp
+await using var factory = new LambdaApplicationFactory<Program>().WithHostBuilder(builder =>
+{
+    builder.ConfigureServices((_, services) =>
+    {
+        // Override registrations or configuration for this test run
+    });
+});
+```
+
+Use `LambdaServerOptions` to tweak runtime details such as timeouts, ARN, or custom headers returned
+by the simulated Runtime API.
+
+## Key Features
+
+- **Runtime-accurate simulation** – Emulates the Lambda Runtime API (init, `/invocation/next`,
+  response/error posts) over an in-memory message channel
+- **End-to-end coverage** – Drives source-generated handlers, middleware, envelopes, DI scopes, and
+  lifecycle hooks exactly as they run in production
+- **Typed invocation helpers** – `InvokeAsync<TEvent, TResponse>` returns structured
+  `InvocationResponse` objects with success flags and error payloads
+- **Host customization** – `WithHostBuilder` and `LambdaApplicationFactoryContentRootAttribute`
+  mirror ASP.NET testing patterns for overriding configuration and locating content roots
+- **Concurrency safe** – Handles multiple pending invocations FIFO with per-request correlation
+
+## Other Packages
+
+Additional packages in the minimal-lambda framework for abstractions, observability, and event
+source handling.
+
+| Package                                                                                                       | NuGet                                                                                                                                                          | Downloads                                                                                                                                                            |
+|---------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| [**MinimalLambda**](../MinimalLambda/README.md)                                                               | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.svg)](https://www.nuget.org/packages/MinimalLambda)                                                     | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.svg)](https://www.nuget.org/packages/MinimalLambda/)                                                     |
+| [**MinimalLambda.Abstractions**](../MinimalLambda.Abstractions/README.md)                                     | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Abstractions.svg)](https://www.nuget.org/packages/MinimalLambda.Abstractions)                           | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Abstractions.svg)](https://www.nuget.org/packages/MinimalLambda.Abstractions/)                           |
+| [**MinimalLambda.OpenTelemetry**](../MinimalLambda.OpenTelemetry/README.md)                                   | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.OpenTelemetry.svg)](https://www.nuget.org/packages/MinimalLambda.OpenTelemetry)                         | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.OpenTelemetry.svg)](https://www.nuget.org/packages/MinimalLambda.OpenTelemetry/)                         |
+| [**MinimalLambda.Envelopes.Sqs**](../Envelopes/MinimalLambda.Envelopes.Sqs/README.md)                         | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.Sqs.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Sqs)                         | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.Sqs.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Sqs/)                         |
+| [**MinimalLambda.Envelopes.ApiGateway**](../Envelopes/MinimalLambda.Envelopes.ApiGateway/README.md)           | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.ApiGateway.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.ApiGateway)           | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.ApiGateway.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.ApiGateway/)           |
+| [**MinimalLambda.Envelopes.Sns**](../Envelopes/MinimalLambda.Envelopes.Sns/README.md)                         | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.Sns.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Sns)                         | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.Sns.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Sns/)                         |
+| [**MinimalLambda.Envelopes.Kinesis**](../Envelopes/MinimalLambda.Envelopes.Kinesis/README.md)                 | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.Kinesis.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Kinesis)                 | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.Kinesis.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Kinesis/)                 |
+| [**MinimalLambda.Envelopes.KinesisFirehose**](../Envelopes/MinimalLambda.Envelopes.KinesisFirehose/README.md) | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.KinesisFirehose.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.KinesisFirehose) | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.KinesisFirehose.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.KinesisFirehose/) |
+| [**MinimalLambda.Envelopes.Kafka**](../Envelopes/MinimalLambda.Envelopes.Kafka/README.md)                     | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.Kafka.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Kafka)                     | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.Kafka.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Kafka/)                     |
+| [**MinimalLambda.Envelopes.CloudWatchLogs**](../Envelopes/MinimalLambda.Envelopes.CloudWatchLogs/README.md)   | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.CloudWatchLogs.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.CloudWatchLogs)   | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.CloudWatchLogs.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.CloudWatchLogs/)   |
+| [**MinimalLambda.Envelopes.Alb**](../Envelopes/MinimalLambda.Envelopes.Alb/README.md)                         | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.Alb.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Alb)                         | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.Alb.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Alb/)                         |
+
+## License
+
+This project is licensed under the MIT License. See [LICENSE](../../LICENSE) for details.
diff --git a/src/MinimalLambda.Testing/TaskHelpers.cs b/src/MinimalLambda.Testing/TaskHelpers.cs
new file mode 100644
index 00000000..dede2c6d
--- /dev/null
+++ b/src/MinimalLambda.Testing/TaskHelpers.cs
@@ -0,0 +1,47 @@
+namespace MinimalLambda.Testing;
+
+internal static class TaskHelpers
+{
+    internal static async Task<Exception[]> WhenAny(params Task[] tasks)
+    {
+        await Task.WhenAny(tasks);
+        return ExtractExceptions(tasks);
+    }
+
+    internal static async Task<Exception[]> WhenAll(params Task[] tasks)
+    {
+        await Task.WhenAll(tasks);
+        return ExtractExceptions(tasks);
+    }
+
+    private static Exception[] ExtractExceptions(Task[] tasks) =>
+        tasks
+            .Where(t =>
+                t
+                    is { Exception: not null }
+                        or Task<Exception?> { Status: TaskStatus.RanToCompletion, Result: not null }
+                        or Task<object?> { Status: TaskStatus.RanToCompletion, Result: Exception }
+            )
+            .Select(t =>
+                t switch
+                {
+                    { Exception: not null } => t.Exception!,
+                    Task<Exception?> { Result: { } ex } => ex,
+                    Task<object?> { Result: Exception ex } => ex,
+                    _ => null,
+                }
+            )
+            .Where(static ex => ex is not null)
+            .Cast<Exception>()
+            .ToArray();
+
+    extension(Task<Exception[]> exceptionsTask)
+    {
+        internal async Task UnwrapAndThrow(string errorMessage)
+        {
+            var exceptions = await exceptionsTask;
+            if (exceptions.Length > 0)
+                throw new AggregateException(errorMessage, exceptions);
+        }
+    }
+}
diff --git a/src/MinimalLambda/Runtime/LambdaHostedService.cs b/src/MinimalLambda/Runtime/LambdaHostedService.cs
index 7557a17a..c1ccd572 100644
--- a/src/MinimalLambda/Runtime/LambdaHostedService.cs
+++ b/src/MinimalLambda/Runtime/LambdaHostedService.cs
@@ -117,14 +117,15 @@ public async Task StopAsync(CancellationToken cancellationToken)
         }
         catch (TaskCanceledException)
         {
-            // if the task completes due to the cancellation token triggering, then we need to tell
-            // the user that shutdown failed
-            exceptions.Add(
-                new OperationCanceledException(
-                    "Graceful shutdown of the Lambda function failed: the bootstrap operation did "
-                        + "not complete within the allocated timeout period."
-                )
-            );
+            // If cancellation was requested, the task timed out during shutdown - report error.
+            // Otherwise, the TaskCanceledException came from LambdaBootstrap and can be ignored.
+            if (cancellationToken.IsCancellationRequested)
+                exceptions.Add(
+                    new OperationCanceledException(
+                        "Graceful shutdown of the Lambda function failed: the bootstrap operation did "
+                            + "not complete within the allocated timeout period."
+                    )
+                );
         }
         catch (Exception ex)
         {
diff --git a/tests/MinimalLambda.Envelopes.UnitTests/MinimalLambda.Envelopes.UnitTests.csproj b/tests/MinimalLambda.Envelopes.UnitTests/MinimalLambda.Envelopes.UnitTests.csproj
index e6b4c2bf..0f54f85c 100644
--- a/tests/MinimalLambda.Envelopes.UnitTests/MinimalLambda.Envelopes.UnitTests.csproj
+++ b/tests/MinimalLambda.Envelopes.UnitTests/MinimalLambda.Envelopes.UnitTests.csproj
@@ -17,6 +17,8 @@
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
     </PackageReference>
+    <PackageReference Include="System.Net.Http" />
+    <PackageReference Include="System.Text.RegularExpressions" />
     <PackageReference Include="xunit.runner.visualstudio">
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
diff --git a/tests/MinimalLambda.OpenTelemetry.UnitTests/MinimalLambda.OpenTelemetry.UnitTests.csproj b/tests/MinimalLambda.OpenTelemetry.UnitTests/MinimalLambda.OpenTelemetry.UnitTests.csproj
index 4d2e6a12..47ac57d3 100644
--- a/tests/MinimalLambda.OpenTelemetry.UnitTests/MinimalLambda.OpenTelemetry.UnitTests.csproj
+++ b/tests/MinimalLambda.OpenTelemetry.UnitTests/MinimalLambda.OpenTelemetry.UnitTests.csproj
@@ -22,6 +22,8 @@
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
     </PackageReference>
     <PackageReference Include="OpenTelemetry.Exporter.InMemory" />
+    <PackageReference Include="System.Net.Http" />
+    <PackageReference Include="System.Text.RegularExpressions" />
     <PackageReference Include="xunit.runner.visualstudio">
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/MinimalLambda.Testing.UnitTests.DiLambda.csproj b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/MinimalLambda.Testing.UnitTests.DiLambda.csproj
new file mode 100644
index 00000000..e26f02d7
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/MinimalLambda.Testing.UnitTests.DiLambda.csproj
@@ -0,0 +1,28 @@
+﻿<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
+    <LangVersion>preview</LangVersion>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
+    <AWSProjectType>Lambda</AWSProjectType>
+    <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
+    <PublishReadyToRun>true</PublishReadyToRun>
+    <InterceptorsNamespaces>$(InterceptorsNamespaces);MinimalLambda.Generated</InterceptorsNamespaces>
+    <IsPackable>false</IsPackable>
+  </PropertyGroup>
+  <ItemGroup>
+    <ProjectReference
+      Include="..\..\..\..\src\MinimalLambda.SourceGenerators\MinimalLambda.SourceGenerators.csproj"
+      ReferenceOutputAssembly="false"
+      OutputItemType="Analyzer"
+    />
+    <ProjectReference Include="..\..\..\..\src\MinimalLambda\MinimalLambda.csproj" />
+  </ItemGroup>
+  <ItemGroup>
+    <None Include="appsettings.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
+  </ItemGroup>
+</Project>
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Program.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Program.cs
new file mode 100644
index 00000000..5cae4ce2
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Program.cs
@@ -0,0 +1,77 @@
+﻿using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using MinimalLambda.Builder;
+
+var builder = LambdaApplication.CreateBuilder();
+
+builder.Services.AddSingleton<ILifecycleService, LifecycleService>();
+builder.Services.AddSingleton<IService, Service>();
+
+await using var lambda = builder.Build();
+
+lambda.OnInit(
+    (ILifecycleService service, ILogger<DiLambda> logger) =>
+    {
+        logger.LogInformation("Init 1");
+        return service.OnStart();
+    }
+);
+
+lambda.UseMiddleware(
+    async (context, next) =>
+    {
+        var logger = context.ServiceProvider.GetRequiredService<ILogger<DiLambda>>();
+
+        logger.LogInformation("Middleware 1: Before");
+        await next(context);
+        logger.LogInformation("Middleware 1: After");
+    }
+);
+
+lambda.MapHandler(
+    ([Event] DiLambdaRequest diLambdaRequest, IService service, ILogger<DiLambda> logger) =>
+    {
+        logger.LogInformation("Lambda handler");
+        return new DiLambdaResponse(service.GetMessage(diLambdaRequest.Name), DateTime.UtcNow);
+    }
+);
+
+lambda.OnShutdown(
+    (ILifecycleService service, ILogger<DiLambda> logger) =>
+    {
+        logger.LogInformation("Shutdown 1");
+        service.OnStop();
+    }
+);
+
+await lambda.RunAsync();
+
+public class DiLambda;
+
+internal record DiLambdaRequest(string Name);
+
+internal record DiLambdaResponse(string Message, DateTime TimestampUtc);
+
+internal interface IService
+{
+    string GetMessage(string name);
+}
+
+internal class Service : IService
+{
+    public string GetMessage(string name) => $"Hello {name}!";
+}
+
+internal interface ILifecycleService
+{
+    bool OnStart();
+    void OnStop();
+}
+
+internal class LifecycleService : ILifecycleService
+{
+    public bool OnStart() => true;
+
+    public void OnStop() { }
+}
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Properties/AssemblyInfo.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Properties/AssemblyInfo.cs
new file mode 100644
index 00000000..3af1a611
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Properties/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("MinimalLambda.Testing.UnitTests")]
+[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/appsettings.json b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/appsettings.json
new file mode 100644
index 00000000..9e26dfee
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/appsettings.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/MinimalLambda.Testing.UnitTests.NoEventLambda.csproj b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/MinimalLambda.Testing.UnitTests.NoEventLambda.csproj
new file mode 100644
index 00000000..e26f02d7
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/MinimalLambda.Testing.UnitTests.NoEventLambda.csproj
@@ -0,0 +1,28 @@
+﻿<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
+    <LangVersion>preview</LangVersion>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
+    <AWSProjectType>Lambda</AWSProjectType>
+    <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
+    <PublishReadyToRun>true</PublishReadyToRun>
+    <InterceptorsNamespaces>$(InterceptorsNamespaces);MinimalLambda.Generated</InterceptorsNamespaces>
+    <IsPackable>false</IsPackable>
+  </PropertyGroup>
+  <ItemGroup>
+    <ProjectReference
+      Include="..\..\..\..\src\MinimalLambda.SourceGenerators\MinimalLambda.SourceGenerators.csproj"
+      ReferenceOutputAssembly="false"
+      OutputItemType="Analyzer"
+    />
+    <ProjectReference Include="..\..\..\..\src\MinimalLambda\MinimalLambda.csproj" />
+  </ItemGroup>
+  <ItemGroup>
+    <None Include="appsettings.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
+  </ItemGroup>
+</Project>
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs
new file mode 100644
index 00000000..238e3d57
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs
@@ -0,0 +1,22 @@
+﻿using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using MinimalLambda.Builder;
+
+var builder = LambdaApplication.CreateBuilder();
+
+builder.Configuration.AddInMemoryCollection(
+    new Dictionary<string, string> { ["MESSAGE"] = "Hello World!" }!
+);
+
+await using var lambda = builder.Build();
+
+lambda.MapHandler(
+    (IConfiguration configuration) =>
+        new NoEventLambdaResponse(configuration["MESSAGE"] ?? "NOT_FOUND", DateTime.UtcNow)
+);
+
+await lambda.RunAsync();
+
+public class NoEventLambda;
+
+internal record NoEventLambdaResponse(string Message, DateTime TimestampUtc);
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Properties/AssemblyInfo.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Properties/AssemblyInfo.cs
new file mode 100644
index 00000000..e3d42db9
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("MinimalLambda.Testing.UnitTests")]
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/appsettings.json b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/appsettings.json
new file mode 100644
index 00000000..9e26dfee
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/appsettings.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/MinimalLambda.Testing.UnitTests.NoResponseLambda.csproj b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/MinimalLambda.Testing.UnitTests.NoResponseLambda.csproj
new file mode 100644
index 00000000..e26f02d7
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/MinimalLambda.Testing.UnitTests.NoResponseLambda.csproj
@@ -0,0 +1,28 @@
+﻿<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
+    <LangVersion>preview</LangVersion>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
+    <AWSProjectType>Lambda</AWSProjectType>
+    <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
+    <PublishReadyToRun>true</PublishReadyToRun>
+    <InterceptorsNamespaces>$(InterceptorsNamespaces);MinimalLambda.Generated</InterceptorsNamespaces>
+    <IsPackable>false</IsPackable>
+  </PropertyGroup>
+  <ItemGroup>
+    <ProjectReference
+      Include="..\..\..\..\src\MinimalLambda.SourceGenerators\MinimalLambda.SourceGenerators.csproj"
+      ReferenceOutputAssembly="false"
+      OutputItemType="Analyzer"
+    />
+    <ProjectReference Include="..\..\..\..\src\MinimalLambda\MinimalLambda.csproj" />
+  </ItemGroup>
+  <ItemGroup>
+    <None Include="appsettings.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
+  </ItemGroup>
+</Project>
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Program.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Program.cs
new file mode 100644
index 00000000..601f87cc
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Program.cs
@@ -0,0 +1,14 @@
+﻿using Microsoft.Extensions.Hosting;
+using MinimalLambda.Builder;
+
+var builder = LambdaApplication.CreateBuilder();
+
+await using var lambda = builder.Build();
+
+lambda.MapHandler(([Event] NoResponseLambdaRequest request) => { });
+
+await lambda.RunAsync();
+
+public class NoResponseLambda;
+
+internal record NoResponseLambdaRequest(string Name);
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Properties/AssemblyInfo.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Properties/AssemblyInfo.cs
new file mode 100644
index 00000000..e3d42db9
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("MinimalLambda.Testing.UnitTests")]
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/appsettings.json b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/appsettings.json
new file mode 100644
index 00000000..9e26dfee
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/appsettings.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/MinimalLambda.Testing.UnitTests.SimpleLambda.csproj b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/MinimalLambda.Testing.UnitTests.SimpleLambda.csproj
new file mode 100644
index 00000000..e26f02d7
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/MinimalLambda.Testing.UnitTests.SimpleLambda.csproj
@@ -0,0 +1,28 @@
+﻿<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
+    <LangVersion>preview</LangVersion>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
+    <AWSProjectType>Lambda</AWSProjectType>
+    <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
+    <PublishReadyToRun>true</PublishReadyToRun>
+    <InterceptorsNamespaces>$(InterceptorsNamespaces);MinimalLambda.Generated</InterceptorsNamespaces>
+    <IsPackable>false</IsPackable>
+  </PropertyGroup>
+  <ItemGroup>
+    <ProjectReference
+      Include="..\..\..\..\src\MinimalLambda.SourceGenerators\MinimalLambda.SourceGenerators.csproj"
+      ReferenceOutputAssembly="false"
+      OutputItemType="Analyzer"
+    />
+    <ProjectReference Include="..\..\..\..\src\MinimalLambda\MinimalLambda.csproj" />
+  </ItemGroup>
+  <ItemGroup>
+    <None Include="appsettings.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
+  </ItemGroup>
+</Project>
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Program.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Program.cs
new file mode 100644
index 00000000..75ae8144
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Program.cs
@@ -0,0 +1,20 @@
+﻿using Microsoft.Extensions.Hosting;
+using MinimalLambda.Builder;
+
+var builder = LambdaApplication.CreateBuilder();
+
+await using var lambda = builder.Build();
+
+lambda.MapHandler(
+    ([Event] string name) =>
+    {
+        if (string.IsNullOrWhiteSpace(name))
+            throw new Exception("Name is required");
+
+        return $"Hello {name}!";
+    }
+);
+
+await lambda.RunAsync();
+
+public class SimpleLambda;
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Properties/AssemblyInfo.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Properties/AssemblyInfo.cs
new file mode 100644
index 00000000..e3d42db9
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("MinimalLambda.Testing.UnitTests")]
diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/appsettings.json b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/appsettings.json
new file mode 100644
index 00000000..9e26dfee
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/appsettings.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/AutoNSubstituteDataAttribute.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/AutoNSubstituteDataAttribute.cs
new file mode 100644
index 00000000..cb3b4557
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/AutoNSubstituteDataAttribute.cs
@@ -0,0 +1,15 @@
+using AutoFixture.AutoNSubstitute;
+using AutoFixture.Xunit3;
+
+namespace MinimalLambda.UnitTests;
+
+public class AutoNSubstituteDataAttribute()
+    : AutoDataAttribute(() =>
+        new Fixture().Customize(new AutoNSubstituteCustomization { ConfigureMembers = true })
+    );
+
+public class InlineAutoNSubstituteDataAttribute : InlineAutoDataAttribute
+{
+    public InlineAutoNSubstituteDataAttribute(params object[] args)
+        : base(new AutoNSubstituteDataAttribute(), args) { }
+}
diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs
new file mode 100644
index 00000000..7d1d0599
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs
@@ -0,0 +1,187 @@
+using Autofac;
+using Autofac.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using MinimalLambda.UnitTests;
+using NSubstitute.ExceptionExtensions;
+
+namespace MinimalLambda.Testing.UnitTests;
+
+public class DiLambdaTests
+{
+    [Fact]
+    public async Task DiLambda_ReturnsExpectedValue()
+    {
+        await using var factory = new LambdaApplicationFactory<DiLambda>().WithCancellationToken(
+            TestContext.Current.CancellationToken
+        );
+
+        var response = await factory.TestServer.InvokeAsync<DiLambdaRequest, DiLambdaResponse>(
+            new DiLambdaRequest("World"),
+            TestContext.Current.CancellationToken
+        );
+
+        response.Should().NotBeNull();
+        response.WasSuccess.Should().BeTrue();
+        response.Response.Should().NotBeNull();
+        response.Response.Message.Should().Be("Hello World!");
+    }
+
+    [Fact]
+    internal async Task DiLambda_InitStopped()
+    {
+        var lifecycleService = Substitute.For<ILifecycleService>();
+        await using var factory = new LambdaApplicationFactory<DiLambda>()
+            .WithCancellationToken(TestContext.Current.CancellationToken)
+            .WithHostBuilder(builder =>
+                builder.ConfigureServices(
+                    (_, services) =>
+                    {
+                        services.RemoveAll<ILifecycleService>();
+                        services.AddSingleton<ILifecycleService>(_ => lifecycleService);
+                    }
+                )
+            );
+
+        lifecycleService.OnStart().Returns(false);
+
+        var initResult = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken);
+        initResult.InitStatus.Should().Be(InitStatus.HostExited);
+    }
+
+    [Fact]
+    internal async Task DiLambda_InitThrowsException()
+    {
+        using var cts = CancellationTokenSource.CreateLinkedTokenSource(
+            TestContext.Current.CancellationToken
+        );
+        cts.CancelAfter(TimeSpan.FromSeconds(5));
+
+        var lifecycleService = Substitute.For<ILifecycleService>();
+
+        await using var factory = new LambdaApplicationFactory<DiLambda>()
+            .WithCancellationToken(cts.Token)
+            .WithHostBuilder(builder =>
+                builder.ConfigureServices(
+                    (_, services) =>
+                    {
+                        services.RemoveAll<ILifecycleService>();
+                        services.AddSingleton<ILifecycleService>(_ => lifecycleService);
+                    }
+                )
+            );
+
+        lifecycleService.OnStart().Throws(new Exception("Test init error"));
+
+        var initResult = await factory.TestServer.StartAsync(cts.Token);
+        initResult.InitStatus.Should().Be(InitStatus.InitError);
+        initResult.Error.Should().NotBeNull();
+        initResult
+            .Error.ErrorMessage.Should()
+            .Be("Encountered errors while running OnInit handlers: (Test init error)");
+    }
+
+    [Theory]
+    [AutoNSubstituteData]
+    internal async Task DiLambda_ShutdownThrowsException(ILifecycleService lifecycleService)
+    {
+        await using var factory = new LambdaApplicationFactory<DiLambda>()
+            .WithCancellationToken(TestContext.Current.CancellationToken)
+            .WithHostBuilder(builder =>
+                builder.ConfigureServices(
+                    (_, services) =>
+                    {
+                        services.RemoveAll<ILifecycleService>();
+                        services.AddSingleton<ILifecycleService>(_ => lifecycleService);
+                    }
+                )
+            );
+
+        lifecycleService.OnStart().Returns(true);
+        lifecycleService.When(x => x.OnStop()).Do(_ => throw new Exception("Test init error"));
+
+        var initResult = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken);
+        initResult.InitStatus.Should().Be(InitStatus.InitCompleted);
+
+        var act = async () =>
+            await factory.TestServer.StopAsync(TestContext.Current.CancellationToken);
+
+        (await act.Should().ThrowAsync<AggregateException>())
+            .WithInnerException<AggregateException>()
+            .WithInnerException<AggregateException>()
+            .WithInnerException<Exception>()
+            .WithMessage("Test init error");
+    }
+
+    [Theory]
+    [AutoNSubstituteData]
+    internal async Task DiLambda_DiContainerCanBeReplaced(
+        ILifecycleService lifecycleService,
+        IService service
+    )
+    {
+        await using var factory = new LambdaApplicationFactory<DiLambda>()
+            .WithCancellationToken(TestContext.Current.CancellationToken)
+            .WithHostBuilder(builder =>
+                builder
+                    .ConfigureContainer<ContainerBuilder>(
+                        (_, containerBuilder) =>
+                        {
+                            containerBuilder.RegisterInstance(service).As<IService>();
+                            containerBuilder
+                                .RegisterInstance(lifecycleService)
+                                .As<ILifecycleService>();
+                        }
+                    )
+                    .UseServiceProviderFactory(new AutofacServiceProviderFactory())
+            );
+
+        service.GetMessage(Arg.Any<string>()).Returns("Hello Bob!");
+
+        var response = await factory.TestServer.InvokeAsync<DiLambdaRequest, DiLambdaResponse>(
+            new DiLambdaRequest("World"),
+            TestContext.Current.CancellationToken
+        );
+
+        response.Should().NotBeNull();
+        response.WasSuccess.Should().BeTrue();
+        response.Response.Should().NotBeNull();
+        response.Response.Message.Should().Be("Hello Bob!");
+    }
+
+    [Theory]
+    [AutoNSubstituteData]
+    internal async Task DiLambda_DiContainerCanBeReplacedWithFactory(
+        ILifecycleService lifecycleService,
+        IService service
+    )
+    {
+        await using var factory = new LambdaApplicationFactory<DiLambda>()
+            .WithCancellationToken(TestContext.Current.CancellationToken)
+            .WithHostBuilder(builder =>
+                builder
+                    .ConfigureContainer<ContainerBuilder>(
+                        (_, containerBuilder) =>
+                        {
+                            containerBuilder.RegisterInstance(service).As<IService>();
+                            containerBuilder
+                                .RegisterInstance(lifecycleService)
+                                .As<ILifecycleService>();
+                        }
+                    )
+                    .UseServiceProviderFactory(_ => new AutofacServiceProviderFactory())
+            );
+
+        service.GetMessage(Arg.Any<string>()).Returns("Hello Joe!");
+
+        var response = await factory.TestServer.InvokeAsync<DiLambdaRequest, DiLambdaResponse>(
+            new DiLambdaRequest("World"),
+            TestContext.Current.CancellationToken
+        );
+
+        response.Should().NotBeNull();
+        response.WasSuccess.Should().BeTrue();
+        response.Response.Should().NotBeNull();
+        response.Response.Message.Should().Be("Hello Joe!");
+    }
+}
diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DictionaryExtensionsTest.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DictionaryExtensionsTest.cs
new file mode 100644
index 00000000..46206a9e
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DictionaryExtensionsTest.cs
@@ -0,0 +1,48 @@
+using JetBrains.Annotations;
+
+namespace MinimalLambda.Testing.UnitTests;
+
+[TestSubject(typeof(DictionaryExtensions))]
+public class DictionaryExtensionsTest
+{
+    private readonly Dictionary<string, string> _dictionary = new()
+    {
+        ["key1"] = "value1",
+        ["key2"] = "value2",
+    };
+
+    [Fact]
+    public void GetRequired_WhenKeyExists_ReturnsValue()
+    {
+        _dictionary.GetRequired("key1", out var value);
+
+        value.Should().Be("value1");
+    }
+
+    [Fact]
+    public void AddRequired_WhenKeyAlreadyExists_ThrowsInvalidOperationException()
+    {
+        var act = () => _dictionary.AddRequired("key1", "newValue");
+
+        act.Should().Throw<InvalidOperationException>().WithMessage("Key 'key1' already exists.");
+    }
+
+    [Fact]
+    public void AddRequired_WhenKeyIsNew_AddsValue()
+    {
+        var act = () => _dictionary.AddRequired("key3", "value3");
+
+        act.Should().NotThrow();
+        _dictionary["key3"].Should().Be("value3");
+    }
+
+    [Fact]
+    public void GetRequired_WhenKeyIsMissing_ThrowsInvalidOperationException()
+    {
+        var act = () => _dictionary.GetRequired("missing", out _);
+
+        act.Should()
+            .Throw<InvalidOperationException>()
+            .WithMessage("Key 'missing' is null or does not exist.");
+    }
+}
diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/GlobalUsings.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/GlobalUsings.cs
new file mode 100644
index 00000000..bd60b342
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/GlobalUsings.cs
@@ -0,0 +1,4 @@
+global using AutoFixture;
+global using AwesomeAssertions;
+global using NSubstitute;
+global using Xunit;
diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/MinimalLambda.Testing.UnitTests.csproj b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/MinimalLambda.Testing.UnitTests.csproj
new file mode 100644
index 00000000..657cdaec
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/MinimalLambda.Testing.UnitTests.csproj
@@ -0,0 +1,37 @@
+﻿<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <IsPackable>false</IsPackable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Autofac.Extensions.DependencyInjection" />
+    <PackageReference Include="AutoFixture.Xunit3" />
+    <PackageReference Include="JetBrains.Annotations" />
+    <PackageReference Include="Microsoft.NET.Test.Sdk" />
+    <PackageReference Include="NSubstitute" />
+    <PackageReference Include="AutoFixture" />
+    <PackageReference Include="AwesomeAssertions" />
+    <PackageReference Include="coverlet.collector" />
+    <PackageReference Include="AutoFixture.AutoNSubstitute" />
+    <PackageReference Include="NSubstitute.Analyzers.CSharp">
+      <PrivateAssets>all</PrivateAssets>
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+    </PackageReference>
+    <PackageReference Include="xunit.runner.visualstudio">
+      <PrivateAssets>all</PrivateAssets>
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+    </PackageReference>
+    <PackageReference Include="xunit.v3" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\..\..\..\src\MinimalLambda.Testing\MinimalLambda.Testing.csproj" />
+    <ProjectReference Include="..\..\Lambdas\MinimalLambda.Testing.UnitTests.DiLambda\MinimalLambda.Testing.UnitTests.DiLambda.csproj" />
+    <ProjectReference Include="..\..\Lambdas\MinimalLambda.Testing.UnitTests.NoEventLambda\MinimalLambda.Testing.UnitTests.NoEventLambda.csproj" />
+    <ProjectReference Include="..\..\Lambdas\MinimalLambda.Testing.UnitTests.NoResponseLambda\MinimalLambda.Testing.UnitTests.NoResponseLambda.csproj" />
+    <ProjectReference Include="..\..\Lambdas\MinimalLambda.Testing.UnitTests.SimpleLambda\MinimalLambda.Testing.UnitTests.SimpleLambda.csproj" />
+  </ItemGroup>
+</Project>
diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs
new file mode 100644
index 00000000..1ceef171
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs
@@ -0,0 +1,48 @@
+using Microsoft.Extensions.Configuration;
+
+namespace MinimalLambda.Testing.UnitTests;
+
+public class NoEventLambdaTests
+{
+    [Fact]
+    public async Task NoEvent_ReturnsExpectedValue()
+    {
+        await using var factory =
+            new LambdaApplicationFactory<NoEventLambda>().WithCancellationToken(
+                TestContext.Current.CancellationToken
+            );
+
+        var response = await factory.TestServer.InvokeNoEventAsync<NoEventLambdaResponse>(
+            TestContext.Current.CancellationToken
+        );
+
+        response.Should().NotBeNull();
+        response.WasSuccess.Should().BeTrue();
+        response.Response.Should().NotBeNull();
+        response.Response.Message.Should().Be("Hello World!");
+    }
+
+    [Fact]
+    public async Task NoEvent_ConfigurationCanBeOverwritten()
+    {
+        await using var factory = new LambdaApplicationFactory<NoEventLambda>()
+            .WithCancellationToken(TestContext.Current.CancellationToken)
+            .WithHostBuilder(builder =>
+                builder.ConfigureAppConfiguration(
+                    (_, config) =>
+                        config.AddInMemoryCollection(
+                            new Dictionary<string, string> { ["MESSAGE"] = "Hello Mars!" }!
+                        )
+                )
+            );
+
+        var response = await factory.TestServer.InvokeNoEventAsync<NoEventLambdaResponse>(
+            TestContext.Current.CancellationToken
+        );
+
+        response.Should().NotBeNull();
+        response.WasSuccess.Should().BeTrue();
+        response.Response.Should().NotBeNull();
+        response.Response.Message.Should().Be("Hello Mars!");
+    }
+}
diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs
new file mode 100644
index 00000000..57741c42
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs
@@ -0,0 +1,54 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace MinimalLambda.Testing.UnitTests;
+
+public class NoResponseLambdaTests
+{
+    [Fact]
+    public async Task NoResponseLambda_ReturnsExpectedValue()
+    {
+        await using var factory =
+            new LambdaApplicationFactory<NoResponseLambda>().WithCancellationToken(
+                TestContext.Current.CancellationToken
+            );
+
+        var response = await factory.TestServer.InvokeNoResponseAsync(
+            new NoResponseLambdaRequest("World"),
+            TestContext.Current.CancellationToken
+        );
+
+        response.WasSuccess.Should().BeTrue();
+        response.Should().NotBeNull();
+    }
+
+    [Fact]
+    public async Task NoResponseLambda_ServicesIsAccessible()
+    {
+        await using var factory =
+            new LambdaApplicationFactory<NoResponseLambda>().WithCancellationToken(
+                TestContext.Current.CancellationToken
+            );
+
+        var act = () => factory.TestServer.Services.GetRequiredService<IHostApplicationLifetime>();
+
+        act.Should().NotThrow();
+    }
+
+    [Fact]
+    public async Task NoResponseLambda_DisposeCanBeCalledMultipleTimes()
+    {
+        await using var factory =
+            new LambdaApplicationFactory<NoResponseLambda>().WithCancellationToken(
+                TestContext.Current.CancellationToken
+            );
+
+        var act = async () =>
+        {
+            await factory.TestServer.DisposeAsync();
+            await factory.TestServer.DisposeAsync();
+        };
+
+        await act.Should().NotThrowAsync();
+    }
+}
diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs
new file mode 100644
index 00000000..48b30e1f
--- /dev/null
+++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs
@@ -0,0 +1,180 @@
+﻿using Microsoft.Extensions.DependencyInjection;
+using MinimalLambda.Options;
+
+namespace MinimalLambda.Testing.UnitTests;
+
+public class SimpleLambdaTests
+{
+    [Fact]
+    public async Task SimpleLambda_ReturnsExpectedValue()
+    {
+        await using var factory =
+            new LambdaApplicationFactory<SimpleLambda>().WithCancellationToken(
+                TestContext.Current.CancellationToken
+            );
+        var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken);
+        setup.InitStatus.Should().Be(InitStatus.InitCompleted);
+
+        var response = await factory.TestServer.InvokeAsync<string, string>(
+            "World",
+            cancellationToken: TestContext.Current.CancellationToken
+        );
+
+        response.WasSuccess.Should().BeTrue();
+        response.Should().NotBeNull();
+        response.Response.Should().Be("Hello World!");
+    }
+
+    [Fact]
+    public async Task SimpleLambda_WorksWhenStartIsNotCalled()
+    {
+        await using var factory =
+            new LambdaApplicationFactory<SimpleLambda>().WithCancellationToken(
+                TestContext.Current.CancellationToken
+            );
+
+        var response = await factory.TestServer.InvokeAsync<string, string>(
+            "World",
+            TestContext.Current.CancellationToken
+        );
+
+        response.WasSuccess.Should().BeTrue();
+        response.Should().NotBeNull();
+        response.Response.Should().Be("Hello World!");
+    }
+
+    [Fact]
+    public async Task SimpleLambda_WorksWhenInvokeCalledMultipleTimes()
+    {
+        await using var factory =
+            new LambdaApplicationFactory<SimpleLambda>().WithCancellationToken(
+                TestContext.Current.CancellationToken
+            );
+
+        // Launch 5 concurrent invocations
+        var tasks = Enumerable
+            .Range(1, 5)
+            .Select(i =>
+                factory.TestServer.InvokeAsync<string, string>(
+                    $"User{i}",
+                    TestContext.Current.CancellationToken
+                )
+            )
+            .ToArray();
+
+        var responses = await Task.WhenAll(tasks);
+
+        responses.Should().AllSatisfy(r => r.WasSuccess.Should().BeTrue());
+
+        responses
+            .Select(r => r.Response)
+            .Should()
+            .ContainInOrder(
+                "Hello User1!",
+                "Hello User2!",
+                "Hello User3!",
+                "Hello User4!",
+                "Hello User5!"
+            );
+    }
+
+    [Fact]
+    public async Task SimpleLambda_WorksWhenInvokeCalledMultipleTimes_WithoutStart()
+    {
+        await using var factory =
+            new LambdaApplicationFactory<SimpleLambda>().WithCancellationToken(
+                TestContext.Current.CancellationToken
+            );
+        await factory.TestServer.StartAsync(TestContext.Current.CancellationToken);
+
+        // Launch 5 concurrent invocations
+        var tasks = Enumerable
+            .Range(1, 5)
+            .Select(i =>
+                factory.TestServer.InvokeAsync<string, string>(
+                    $"User{i}",
+                    TestContext.Current.CancellationToken
+                )
+            )
+            .ToArray();
+
+        var responses = await Task.WhenAll(tasks);
+
+        responses.Should().AllSatisfy(r => r.WasSuccess.Should().BeTrue());
+
+        responses
+            .Select(r => r.Response)
+            .Should()
+            .ContainInOrder(
+                "Hello User1!",
+                "Hello User2!",
+                "Hello User3!",
+                "Hello User4!",
+                "Hello User5!"
+            );
+    }
+
+    [Fact]
+    public async Task SimpleLambda_ReturnsError()
+    {
+        await using var factory =
+            new LambdaApplicationFactory<SimpleLambda>().WithCancellationToken(
+                TestContext.Current.CancellationToken
+            );
+
+        var response = await factory.TestServer.InvokeAsync<string, string>(
+            "",
+            TestContext.Current.CancellationToken
+        );
+
+        response.Should().NotBeNull();
+        response.WasSuccess.Should().BeFalse();
+        response.Error.Should().NotBeNull();
+        response.Error.ErrorMessage.Should().Be("Name is required");
+    }
+
+    [Fact]
+    public async Task SimpleLambda_ErrorsArePropagated()
+    {
+        await using var factory = new LambdaApplicationFactory<SimpleLambda>()
+            .WithCancellationToken(TestContext.Current.CancellationToken)
+            .WithHostBuilder(builder =>
+            {
+                builder.ConfigureServices(
+                    (_, services) =>
+                    {
+                        services.Configure<LambdaHostOptions>(options =>
+                        {
+                            options.BootstrapOptions.RuntimeApiEndpoint = "http://localhost:3002";
+                        });
+                    }
+                );
+            });
+
+        var act = async () =>
+            await factory.TestServer.StartAsync(TestContext.Current.CancellationToken);
+
+        await act.Should()
+            .ThrowAsync<InvalidOperationException>()
+            .WithMessage(
+                "Unexpected request received from the Lambda HTTP handler: GET http://http//localhost:3002/2018-06-01/runtime/invocation/next"
+            );
+    }
+
+    [Fact]
+    public async Task SimpleLambda_WithPreCanceledToken_CancelsInvocation()
+    {
+        await using var factory =
+            new LambdaApplicationFactory<SimpleLambda>().WithCancellationToken(
+                TestContext.Current.CancellationToken
+            );
+        await factory.TestServer.StartAsync(TestContext.Current.CancellationToken);
+
+        using var cts = new CancellationTokenSource();
+        await cts.CancelAsync();
+
+        var act = () => factory.TestServer.InvokeAsync<string, string>("Jonas", cts.Token);
+
+        await act.Should().ThrowAsync<TaskCanceledException>();
+    }
+}
diff --git a/tests/MinimalLambda.UnitTests/MinimalLambda.UnitTests.csproj b/tests/MinimalLambda.UnitTests/MinimalLambda.UnitTests.csproj
index caf6b611..bd37d950 100644
--- a/tests/MinimalLambda.UnitTests/MinimalLambda.UnitTests.csproj
+++ b/tests/MinimalLambda.UnitTests/MinimalLambda.UnitTests.csproj
@@ -23,6 +23,8 @@
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
     </PackageReference>
+    <PackageReference Include="System.Net.Http" />
+    <PackageReference Include="System.Text.RegularExpressions" />
     <PackageReference Include="xunit.runner.visualstudio">
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
