Skip to content

PlumberApplicationFactory

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

PlumberApplicationFactory

PreviewPlumber.Testing lives in the source tree but is not yet published to NuGet. Until it ships, take a project reference to Plumber.Testing directly. 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.

Constructor

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.

Customization hooks

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.

WithBuilder(Action<RequestHandlerBuilder<TReq, TRes>>)

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

WithServices(Action<IServiceCollection>)

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.

WithServices(Action<IServiceCollection, IConfiguration>)

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

WithLogging(Action<ILoggingBuilder>)

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

WithConfiguration(Action<IConfigurationBuilder>)

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

WithInMemorySettings(IEnumerable<KeyValuePair<string, string?>>)

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.

CreateHandler() idempotency and freeze-after-create

public RequestHandler<TRequest, TResponse> CreateHandler();

The first call to CreateHandler() does the real work:

  1. Calls createBuilder(args) to get a builder.
  2. Runs every queued hook against the builder, in registration order.
  3. Calls builder.Build() to construct the handler and its service provider.
  4. Calls configurePipeline(handler) to attach the middleware chain.
  5. 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.

InvokeAsync convenience

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.

Disposal

PlumberApplicationFactory<TReq, TRes> is IDisposable. Wrap it in using:

using var factory = new PlumberApplicationFactory<string, TextReport>(
    Pipeline.CreateBuilder,
    Pipeline.Configure);

Disposal:

  1. Disposes the cached handler (if CreateHandler() was ever called).
  2. The handler's Dispose disposes the service provider it owns.
  3. The service provider transitively disposes the IConfiguration (registered as a singleton during Build()) and any other IDisposable services 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.

Common patterns

End-to-end happy-path test

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

Stubbing one service

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

Seeding configuration

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

Custom TimeProvider for deterministic Elapsed and timeouts

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.

See also

  • Testing — strategy and the direct-invocation alternative
  • Sample.Cli walkthrough — the Pipeline.CreateBuilder / Pipeline.Configure shape this factory expects
  • Building a pipeline — the underlying builder surface
  • AdvancedFakeTimeProvider, multiple Build() calls, host-mode integration

Clone this wiki locally