Skip to content

PlumberApplicationFactory

Mark Lauter edited this page Jun 13, 2026 · 5 revisions

PlumberApplicationFactory

PlumberApplicationFactory<TRequest, TResponse> 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. The shape is modeled on ASP.NET Core's WebApplicationFactory<TEntryPoint>.

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.

Installation

The factory ships in the MSL.Plumber.Pipeline.Testing package. Add it to your test project:

dotnet add package MSL.Plumber.Pipeline.Testing

The package depends on MSL.Plumber.Pipeline and releases at the same version. Install the version matching the MSL.Plumber.Pipeline reference your application uses.

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 constructor is lazy. The builder factory and pipeline configurator are 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>>)

Hands you the raw builder for anything the other hooks omit — AddDefaultConfigurationSources(), chaining several Configure* calls together, or any builder method that has no dedicated wrapper. The other hooks are sugar over this one.

using var factory = new PlumberApplicationFactory<string, TextReport>(
        Pipeline.CreateBuilder,
        Pipeline.Configure)
    .WithBuilder(builder => builder
        .AddDefaultConfigurationSources()
        .ConfigureLogging(logging => logging.AddDebug()));

WithServices(Action<IServiceCollection>)

Swaps or adds service registrations — the most common test-time override. 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 RemoveAll followed by an Add:

.WithServices(services =>
{
    services.RemoveAll<ITokenizer>();
    services.AddSingleton<ITokenizer>(new StubTokenizer(["x"]));
});

Replace from Microsoft.Extensions.DependencyInjection.Extensions is also fine.

WithServices(Action<IServiceCollection, IConfiguration>)

Swaps or adds service registrations with access to the built IConfiguration, so the test can pick implementations based on its own 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>)

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

Adds 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?>>)

Seeds a handful of configuration keys in memory — the most common configuration override.

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.

Freeze-after-create

Once CreateHandler() has run (whether you called it directly or InvokeAsync did), the configuration hooks freeze. 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 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.

Services

public IServiceProvider Services { get; }

The root IServiceProvider of the built pipeline — the WAF analog of factory.Services. Accessing it builds the handler and freezes the hooks, exactly like CreateHandler().

Resolve singletons directly. For scoped services — a DbContext is the canonical case — create a scope first; resolving scoped services from the root provider produces captive dependencies:

await factory.InvokeAsync(new SyncRequest(Since: null));

using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();
Assert.Equal(2, await db.Records.CountAsync());

This is the assertion path for state the pipeline wrote to its own services. State held by the test (an in-memory SQLite connection, a stub's recorded calls) is asserted on directly — no provider required.

Accessing Services after the factory is disposed throws ObjectDisposedException.

Disposal

PlumberApplicationFactory<TReq, TRes> is IDisposable and IAsyncDisposable. Wrap it in using — or await using when the pipeline registers services that implement only IAsyncDisposable:

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

Disposal:

  1. Disposes the cached handler (if CreateHandler() was ever called); DisposeAsync flows through the handler's DisposeAsync.
  2. The handler disposes the service provider it owns.
  3. The service provider transitively disposes the IConfiguration (registered as a singleton during Build()) and any other disposable services it constructed — the async path also handles services that implement only IAsyncDisposable.

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