Skip to content

Testing

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

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.

Why test the pipeline as a whole

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

Factory: PlumberApplicationFactory<TReq, TRes>

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.

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

That 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.

What to assert

The shape of an assertion 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.)

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