-
Notifications
You must be signed in to change notification settings - Fork 0
PlumberApplicationFactory
Preview —
Plumber.Testinglives in the source tree but is not yet published to NuGet. Until it ships, take a project reference toPlumber.Testingdirectly. The API documented here is stable in shape but may evolve before the NuGet release.
PlumberApplicationFactory<TRequest, TResponse> is modeled on ASP.NET Core's WebApplicationFactory<TEntryPoint>. It 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.
If you're looking 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.
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 factory does nothing eager in the constructor. The builder factory and pipeline configurator are both 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.
The escape hatch. The other hooks are sugar over this one; reach for it when you need to do something that doesn't have a dedicated method, like calling AddDefaultConfigurationSources() or chaining multiple Configure* calls in one go.
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithBuilder(builder => builder
.AddDefaultConfigurationSources()
.ConfigureLogging(logging => logging.AddDebug()));Sugar for the most common case: swapping or adding service registrations. 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 Replace:
.WithServices(services =>
{
services.RemoveAll<ITokenizer>();
services.AddSingleton<ITokenizer>(new StubTokenizer(["x"]));
});Replace from Microsoft.Extensions.DependencyInjection.Extensions is also fine.
Same as above, but the callback receives the built IConfiguration so you can pick implementations based on test 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"]));
}
});Adjust 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));Add 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));The most common configuration override: seed a handful of keys in memory.
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 been called (whether you called it directly or InvokeAsync did), the configuration hooks are frozen. 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 need to 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.
PlumberApplicationFactory<TReq, TRes> is IDisposable. Wrap it in using:
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure);Disposal:
- Disposes the cached handler (if
CreateHandler()was ever called). - The handler's
Disposedisposes the service provider it owns. - The service provider transitively disposes the
IConfiguration(registered as a singleton duringBuild()) and any otherIDisposableservices it constructed.
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