-
Notifications
You must be signed in to change notification settings - Fork 0
Testing
Plumber pipelines are easy to test because the public surface is the same one 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. You build the real handler in your test, swap whatever services you want to control, and invoke it.
This page lays out the strategy. The mechanical surface of the testing factory lives on the dedicated PlumberApplicationFactory page.
It's tempting to test each middleware in isolation: instantiate the class, call InvokeAsync with a hand-rolled RequestContext, assert. That's a fine technique for a single piece of branching logic, but it leaves 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 the 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. You 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.
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);
}This is the right choice 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.
| 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>> configurePipelineThat shape exists because pipelines 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.
The shape of an assertion 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.)
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 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