-
Notifications
You must be signed in to change notification settings - Fork 0
Testing
Plumber pipelines test through the same surface you use in production: a builder, a handler, and InvokeAsync. There's no test runner to wire up, no mock host to spin up, no special pipeline-under-test mode. Build the real handler in your test, swap the services you want to control, and invoke it.
The strategy lives here. The mechanical surface of the testing factory lives on the dedicated PlumberApplicationFactory page.
Per-middleware unit tests — instantiate the class, call InvokeAsync with a hand-rolled RequestContext, assert — work fine for a single piece of branching logic. They leave a category of bugs uncovered:
-
Ordering. A
LoggingMiddlewareregistered afterValidationMiddlewarewon't see the validation rejection. A timing wrapper registered after the work it's meant to time produces a near-zeroElapsed. Unit tests that build middleware in isolation can't catch ordering mistakes. -
Service registrations. Class middleware constructed by Plumber pulls dependencies from the DI container. Forgetting to register
ITokenizerproduces a runtime exception on first invocation, not at compile time. A whole-pipeline test catches it. -
Configuration plumbing. If
TokenizerOptionsis meant to be bound fromappsettings.json, a test that hand-constructs the options doesn't exercise the binding. Build the real pipeline and the binding either works or it throws. -
Cross-middleware state. Middleware that reads
context.Data["tokens"]depends on an earlier middleware writing it under that exact key. Constants help (DataKeys), but tests that exercise the chain catch the typos constants can't.
The wiring between middleware is the behavior. Tests that build the real handler exercise the wiring.
There are two ways to test a pipeline. Both build a real handler. They differ in how much of your production setup they reuse.
Construct a builder in the test, register stubs, build, invoke.
using Plumber;
using Microsoft.Extensions.DependencyInjection;
[Fact]
public async Task NormalizeMiddlewareLowercasesInputAsync()
{
using var handler = RequestHandlerBuilder
.Create<string, TextReport>()
.ConfigureServices((services, _) =>
services.AddSingleton<ITokenizer>(new StubTokenizer(["x"])))
.Build()
.Use<NormalizeMiddleware>()
.Use<TokenizeMiddleware>()
.Use<ReportMiddleware>();
var report = await handler.InvokeAsync("Hello, World!");
Assert.NotNull(report);
Assert.Equal("hello, world!", report.Normalized);
}This is the lowest-overhead option. Take a project reference to Plumber and you're done. It's the right choice when:
- You want to exercise a single middleware (or a small slice of the chain) in context, with one or two stubs.
- You're writing a regression test for a specific bug and want full control of the middleware list.
- You're testing a pipeline that doesn't have a production
Pipeline.CreateBuilder/Pipeline.Configurepair (yet).
Build your real production pipeline once per test, then swap services or configuration surgically. The factory takes your application's Pipeline.CreateBuilder and Pipeline.Configure methods, layers With* overrides between them, and produces the same handler your production code runs — with a few targeted swaps.
using Plumber.Testing;
using Microsoft.Extensions.DependencyInjection;
[Fact]
public async Task EndToEndProducesReportAsync()
{
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure);
var report = await factory.InvokeAsync("Hello, World!");
Assert.NotNull(report);
Assert.Equal(2, report.WordCount);
}
[Fact]
public async Task StubbedTokenizerControlsWordCountAsync()
{
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithServices(services =>
services.AddSingleton<ITokenizer>(new StubTokenizer(["a", "b", "c"])));
var report = await factory.InvokeAsync("anything");
Assert.Equal(3, report!.WordCount);
}Reach for the factory when:
- You want every test to assert against the real production wiring — same middleware order, same configuration sources, same service registrations.
- You want to swap one or two services and hold everything else constant.
- You're writing many tests and want the setup to live in one place.
Full surface and hook-by-hook reference on the PlumberApplicationFactory page.
| Need | Reach for |
|---|---|
| Micro-test a slice of the chain with a hand-picked middleware list | Direct |
| End-to-end behavior with the real production pipeline | Factory |
| Surgical service swap on top of the real pipeline | Factory |
Test a pipeline that has no Pipeline.CreateBuilder / Pipeline.Configure split yet |
Direct |
| Vary the timeout per test | Direct (call Build(timeout) yourself) |
Seed IConfiguration keys for one test |
Factory (WithInMemorySettings) |
Both can coexist in the same test project. The direct approach gives you precision; the factory gives you fidelity to production.
The factory takes two functions:
// pseudocode — illustrative type signatures
Func<string[], RequestHandlerBuilder<TReq, TRes>> createBuilder
Func<RequestHandler<TReq, TRes>, RequestHandler<TReq, TRes>> configurePipelinePipelines have two halves: builder configuration (config sources, services, logging) and pipeline configuration (the middleware chain). Splitting them into two methods on a Pipeline static class lets:
-
Production code call them in sequence:
Configure(CreateBuilder(args).Build()). -
The factory wrap them, with
With*hooks layered between the two halves. - Direct tests call either one and override the rest.
The Sample.Cli/Pipeline.cs file is the canonical shape:
internal static class Pipeline
{
public static RequestHandlerBuilder<string, TextReport> CreateBuilder(string[] args) =>
RequestHandlerBuilder.Create<string, TextReport>(args)
.ConfigureConfiguration((config, _) => config.AddInMemoryCollection([/* ... */]))
.ConfigureLogging(logging => logging.AddSimpleConsole(/* ... */))
.ConfigureServices((services, configuration) =>
{
var options = configuration.GetSection(TokenizerOptions.SectionName).Get<TokenizerOptions>()
?? TokenizerOptions.Defaults;
services
.AddSingleton(options)
.AddSingleton<ITokenizer, WhitespaceTokenizer>();
});
public static RequestHandler<string, TextReport> Configure(RequestHandler<string, TextReport> handler) =>
handler
.Use<ValidationMiddleware>()
.Use<NormalizeMiddleware>()
.Use<TokenizeMiddleware>()
.Use<ReportMiddleware>();
public static RequestHandler<string, TextReport> Build(string[] args) =>
Configure(CreateBuilder(args).Build());
}Program.cs calls Pipeline.Build(args). Tests call Pipeline.CreateBuilder and Pipeline.Configure through the factory, or directly when they want full control. Adopt this shape early — the Sample.Cli walkthrough traces through it in detail.
Assertion shape depends on what your pipeline produces. A few common patterns:
var report = await factory.InvokeAsync("Hello, World!");
Assert.NotNull(report);
Assert.Equal("hello, world!", report.Normalized);
Assert.Equal(2, report.WordCount);For Unit-typed pipelines, the response is always default(Unit); assert on side effects instead.
Stub a service, invoke the pipeline, then read state off the stub:
public sealed class RecordingPublisher : IPublisher
{
public List<Message> Published { get; } = [];
public Task PublishAsync(Message m) { Published.Add(m); return Task.CompletedTask; }
}
[Fact]
public async Task PublishesNormalizedMessageAsync()
{
var publisher = new RecordingPublisher();
using var factory = new PlumberApplicationFactory<RawEvent, Unit>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithServices(services => services.AddSingleton<IPublisher>(publisher));
await factory.InvokeAsync(new RawEvent("payload"));
Assert.Single(publisher.Published);
Assert.Equal("PAYLOAD", publisher.Published[0].NormalizedBody);
}InvokeAsync propagates exceptions thrown by middleware:
await Assert.ThrowsAsync<ValidationException>(
() => factory.InvokeAsync(new RawEvent(string.Empty)));Caller cancellation surfaces as OperationCanceledException. Handler timeouts surface as TimeoutException. See Request lifecycle for the full distinction between the two.
For deterministic timing, register FakeTimeProvider (see below). Without it, Elapsed reflects wall-clock time and tests for "took less than 50ms" produce flaky CI runs.
FakeTimeProvider lives in Microsoft.Extensions.TimeProvider.Testing. It's the supported way to control elapsed time and timer firing in tests against any code that takes a dependency on TimeProvider — including Plumber's RequestContext.Elapsed and the handler's built-in timeout.
Register it through the factory:
using Microsoft.Extensions.Time.Testing;
var fakeTime = new FakeTimeProvider();
using var factory = new PlumberApplicationFactory<string, TextReport>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithServices(services => services.AddSingleton<TimeProvider>(fakeTime));
var task = factory.InvokeAsync("Hello");
fakeTime.Advance(TimeSpan.FromMilliseconds(500));
var report = await task;
Assert.Equal(TimeSpan.FromMilliseconds(500), report!.Elapsed);For testing handler timeouts, build a handler with a finite timeout (either through the factory or directly) and advance the fake clock past it. Plumber's timeout source is constructed from the registered TimeProvider, so advancing the fake clock fires the timeout deterministically. Full mechanics live in Advanced.
- PlumberApplicationFactory — full surface documentation for the testing factory
- Sample.Cli walkthrough — guided tour of a pipeline shaped for testing
- Building a pipeline — the builder surface the factory wraps
-
Advanced —
FakeTimeProvider, multipleBuild()calls, host-mode integration
Documents Plumber v3.x · Repository · MIT License · Report an issue
Getting Started
Reference
- Building a pipeline
- Middleware
- Request lifecycle
- Testing
- PlumberApplicationFactory
- Advanced
- FAQ
- Migration
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