-
Notifications
You must be signed in to change notification settings - Fork 0
PlumberApplicationFactory
PlumberApplicationFactory<TRequest, TResponse> wraps your real production pipeline so tests can build it once, swap services or configuration surgically, invoke the pipeline, and dispose everything cleanly when the test ends. The shape is modeled on ASP.NET Core's WebApplicationFactory<TEntryPoint>.
For the strategy-level discussion of when to use the factory versus building a handler directly in your test, see Testing. This page is the surface reference.
The factory ships in the MSL.Plumber.Pipeline.Testing package. Add it to your test project:
dotnet add package MSL.Plumber.Pipeline.TestingThe package depends on MSL.Plumber.Pipeline and releases at the same version. Install the version matching the MSL.Plumber.Pipeline reference your application uses.
public PlumberApplicationFactory(
Func<string[], RequestHandlerBuilder<TRequest, TResponse>> createBuilder,
Func<RequestHandler<TRequest, TResponse>, RequestHandler<TRequest, TResponse>> configurePipeline,
string[]? args = null)| Parameter | Purpose |
|---|---|
createBuilder |
Returns the un-built builder. Typically your application's Pipeline.CreateBuilder method. |
configurePipeline |
Adds middleware to the built handler and returns it. Typically your application's Pipeline.Configure method. |
args |
Command-line args forwarded to the builder. Defaults to an empty array. |
The constructor is lazy. The builder factory and pipeline configurator are stored and only invoked when you call CreateHandler() (or InvokeAsync, which calls it for you). That lazy posture is what makes WithBuilder, WithServices, and the other hooks work — they queue customizations that run between createBuilder and the eventual Build().
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure);The where TRequest : notnull constraint mirrors the rest of Plumber's API: value-type requests work, nullable references are off the table.
All customization hooks return the factory for chaining. All of them queue an action that runs against the builder during CreateHandler(). Multiple hooks compose in registration order. Calling any of them after the handler has been created throws InvalidOperationException — see freeze-after-create.
Hands you the raw builder for anything the other hooks omit — AddDefaultConfigurationSources(), chaining several Configure* calls together, or any builder method that has no dedicated wrapper. The other hooks are sugar over this one.
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithBuilder(builder => builder
.AddDefaultConfigurationSources()
.ConfigureLogging(logging => logging.AddDebug()));Swaps or adds service registrations — the most common test-time override. Internally calls ConfigureServices((services, _) => configure(services)).
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithServices(services =>
services.AddSingleton<ITokenizer>(new StubTokenizer(["a", "b", "c"])));To replace a service registered in Pipeline.CreateBuilder, the simplest pattern is RemoveAll followed by an Add:
.WithServices(services =>
{
services.RemoveAll<ITokenizer>();
services.AddSingleton<ITokenizer>(new StubTokenizer(["x"]));
});Replace from Microsoft.Extensions.DependencyInjection.Extensions is also fine.
Swaps or adds service registrations with access to the built IConfiguration, so the test can pick implementations based on its own settings.
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithInMemorySettings([new("Tokenizer:Mode", "stub")])
.WithServices((services, configuration) =>
{
var mode = configuration["Tokenizer:Mode"];
if (mode == "stub")
{
services.RemoveAll<ITokenizer>();
services.AddSingleton<ITokenizer>(new StubTokenizer(["a", "b"]));
}
});Adjusts logging — usually to add an in-memory provider for assertions, or to bump the level for a specific test.
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithLogging(logging => logging.SetMinimumLevel(LogLevel.Debug));Adds configuration sources without touching the builder directly. Useful for layering test-specific JSON or environment-style sources on top of whatever the production builder configures.
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithConfiguration(config => config
.AddJsonFile("test-overrides.json", optional: false));Seeds a handful of configuration keys in memory — the most common configuration override.
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithInMemorySettings([
new("Tokenizer:Separators", " "),
new("Tokenizer:RemoveEmptyEntries", "true"),
]);In-memory settings layered through this hook are added after the builder's existing sources, so they win for matching keys.
public RequestHandler<TRequest, TResponse> CreateHandler();The first call to CreateHandler() does the real work:
- Calls
createBuilder(args)to get a builder. - Runs every queued hook against the builder, in registration order.
- Calls
builder.Build()to construct the handler and its service provider. - Calls
configurePipeline(handler)to attach the middleware chain. - Caches the resulting handler.
Subsequent calls return the same cached instance. The factory is its own little singleton container.
Once CreateHandler() has run (whether you called it directly or InvokeAsync did), the configuration hooks freeze. Calling WithBuilder, WithServices, WithLogging, WithConfiguration, or WithInMemorySettings after that throws InvalidOperationException. The intent is to make the order of operations obvious in your tests: configure first, then invoke.
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithServices(services =>
services.AddSingleton<ITokenizer>(stub));
var report = await factory.InvokeAsync("Hello"); // freezes the factory
factory.WithLogging(l => l.AddDebug());
// ^ throws InvalidOperationException:
// "cannot configure builder after the handler has been created."If a test legitimately needs two different configurations — say, one with the stub and one with the real tokenizer — construct two factories.
The factory throws ObjectDisposedException from every hook and from CreateHandler() after it's been disposed.
public Task<TResponse?> InvokeAsync(
TRequest request,
CancellationToken cancellationToken = default);Thin wrapper over CreateHandler().InvokeAsync(request, cancellationToken). The vast majority of tests fire one request and assert on the response, so this is the path of least resistance:
var report = await factory.InvokeAsync("Hello, World!");
Assert.Equal("hello, world!", report!.Normalized);Returns Task<TResponse?> because the underlying handler returns TResponse? — a middleware chain that completes without anyone assigning context.Response produces a null response.
For tests that fire many requests through the same handler, hold on to CreateHandler() and invoke it directly:
var handler = factory.CreateHandler();
var first = await handler.InvokeAsync("hello");
var second = await handler.InvokeAsync("world");That avoids the per-call dictionary lookup InvokeAsync does to find the cached handler — minor, but worth knowing for benchmark suites.
public IServiceProvider Services { get; }The root IServiceProvider of the built pipeline — the WAF analog of factory.Services. Accessing it builds the handler and freezes the hooks, exactly like CreateHandler().
Resolve singletons directly. For scoped services — a DbContext is the canonical case — create a scope first; resolving scoped services from the root provider produces captive dependencies:
await factory.InvokeAsync(new SyncRequest(Since: null));
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();
Assert.Equal(2, await db.Records.CountAsync());This is the assertion path for state the pipeline wrote to its own services. State held by the test (an in-memory SQLite connection, a stub's recorded calls) is asserted on directly — no provider required.
Accessing Services after the factory is disposed throws ObjectDisposedException.
PlumberApplicationFactory<TReq, TRes> is IDisposable and IAsyncDisposable. Wrap it in using — or await using when the pipeline registers services that implement only IAsyncDisposable:
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure);Disposal:
- Disposes the cached handler (if
CreateHandler()was ever called);DisposeAsyncflows through the handler'sDisposeAsync. - The handler disposes the service provider it owns.
- The service provider transitively disposes the
IConfiguration(registered as a singleton duringBuild()) and any other disposable services it constructed — the async path also handles services that implement onlyIAsyncDisposable.
If CreateHandler() was unused over the factory's lifetime, disposal is a no-op — there's no handler to dispose and no provider to clean up.
The factory is safe to dispose multiple times; the second call returns immediately.
The simplest test possible: build the real pipeline, invoke it, assert on the response.
[Fact]
public async Task ProducesNormalizedReportAsync()
{
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure);
var report = await factory.InvokeAsync("Hello, World!");
Assert.NotNull(report);
Assert.Equal("hello, world!", report.Normalized);
Assert.Equal(2, report.WordCount);
}Replace a single dependency, hold the rest of the pipeline constant.
[Fact]
public async Task UsesInjectedTokenizerAsync()
{
var stub = new StubTokenizer(["alpha", "beta", "gamma"]);
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithServices(services =>
{
services.RemoveAll<ITokenizer>();
services.AddSingleton<ITokenizer>(stub);
});
var report = await factory.InvokeAsync("anything");
Assert.Equal(3, report!.WordCount);
Assert.Equal(["alpha", "beta", "gamma"], report.Tokens);
}Seed configuration values that the production builder reads through IConfiguration.
[Fact]
public async Task RespectsCommaSeparatorAsync()
{
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithInMemorySettings([
new($"{TokenizerOptions.SectionName}:{nameof(TokenizerOptions.Separators)}", ","),
]);
var report = await factory.InvokeAsync("a,b,c");
Assert.Equal(3, report!.WordCount);
}Register FakeTimeProvider to drive elapsed time and timeout firing from your test.
using Microsoft.Extensions.Time.Testing;
[Fact]
public async Task ReportsControlledElapsedAsync()
{
var fakeTime = new FakeTimeProvider();
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithServices(services =>
{
services.RemoveAll<TimeProvider>();
services.AddSingleton<TimeProvider>(fakeTime);
});
var task = factory.InvokeAsync("Hello");
fakeTime.Advance(TimeSpan.FromMilliseconds(750));
var report = await task;
Assert.Equal(TimeSpan.FromMilliseconds(750), report!.Elapsed);
}The same pattern drives timeout testing — see Advanced for the full mechanics, and the Testing page for when to reach for it.
- Testing — strategy and the direct-invocation alternative
-
Sample.Cli walkthrough — the
Pipeline.CreateBuilder/Pipeline.Configureshape this factory expects - Building a pipeline — the underlying builder surface
-
Advanced —
FakeTimeProvider, multipleBuild()calls, host-mode integration
Documents Plumber v4.x · Repository · MIT License · Report an issue
Getting Started
Pipeline (core)
Testing
Serilog Extensions
Diagnostics
Recipes
- AWS Lambda — API Gateway
- AWS Lambda — SQS
- Azure Functions — HTTP
- SQS polling console
- ASP.NET Core integration
- BackgroundService worker
- Webhook receiver
- Multi-command CLI
- File watcher
- Configuration reload
Repo · NuGet · NuGet — Testing · NuGet — Serilog · NuGet — Diagnostics