Skip to content

Testing

Mark Lauter edited this page May 10, 2026 · 2 revisions

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.

Why test the pipeline as a whole

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 LoggingMiddleware registered after ValidationMiddleware won't see the validation rejection. A timing wrapper registered after the work it's meant to time produces a near-zero Elapsed. 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 ITokenizer produces a runtime exception on first invocation, not at compile time. A whole-pipeline test catches it.
  • Configuration plumbing. If TokenizerOptions is meant to be bound from appsettings.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.

Two approaches

There are two ways to test a pipeline. Both build a real handler. They differ in how much of your production setup they reuse.

Direct: build the handler in the test

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.Configure pair (yet).

Factory: PlumberApplicationFactory<TReq, TRes>

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.

Picking between them

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 CreateBuilder / Configure split

The factory takes two functions:

// pseudocode — illustrative type signatures
Func<string[], RequestHandlerBuilder<TReq, TRes>> createBuilder
Func<RequestHandler<TReq, TRes>, RequestHandler<TReq, TRes>> configurePipeline

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.

What to assert

Assertion shape depends on what your pipeline produces. A few common patterns:

Response shape and values

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.

Side effects on stubbed services

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);
}

Exceptions

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.

Timeouts and elapsed time

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.

A note on FakeTimeProvider

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.

See also

Clone this wiki locally