BrandGhost
How To Unit Test a C# Processing Pipeline

How To Unit Test a C# Processing Pipeline

To unit test C# processing pipeline behavior, start by separating stage correctness from composition correctness. A stage test proves one stage's contract. A composition test proves exact order, context propagation, short-circuiting, fault behavior, and cancellation. Lifecycle tests then prove completion, draining, shutdown, bounded capacity, and cleanup without relying on lucky timing.

The hard part is not choosing an assertion library. It is controlling every boundary that can otherwise become nondeterministic. A test should know when a stage has entered, when it may continue, when cancellation is requested, and when a queue has available capacity. Task.Delay(100) cannot tell you any of those things.

The examples below target the stable .NET 10 support baseline as of August 2026 with C# 14 and nullable reference types enabled. They use dependency-free checks and test doubles because no external test-package version is required or implied. You can move the same arrangements and assertions into your preferred current test framework.

If you want the surrounding framework and mocking mechanics, my xUnit and Moq guide covers that broader foundation. Here, the focus stays on pipeline-specific proof.

The code blocks form one dependency-free console project. After adding the types and checks shown below, use this Program.cs to run the complete set:

using PipelineTestingExample;

await PipelineChecks.RunAsync();
await ChannelChecks.RunAsync();
await TimeChecks.RunAsync();

Console.WriteLine("All pipeline checks passed.");

Unit Test C# Processing Pipeline Contracts Separately

A useful pipeline test suite has several proof levels:

Test level What it proves
Stage contract Success, expected rejection, exception, cancellation, input handling
Composition Exact order, context propagation, short-circuiting, fault propagation
Lifecycle Admission stops, buffered work drains, workers finish, completion is observed
Capacity Writes block when full and respond to cancellation
Ordering Results preserve the ordering guarantee promised by the implementation
Ownership Token sources, scopes, links, payloads, and other resources are cleaned up

Do not make one enormous "everything works" test carry all of that evidence. A failure should identify which contract changed.

This separation is the foundation when you unit test C# processing pipeline behavior. It keeps a stage defect from looking like a shutdown defect and keeps a capacity defect from looking like an ordering defect.

The following small test kit gives us a common vocabulary. It is intentionally plain C#:

namespace PipelineTestingExample;

public sealed record PipelineContext(
    string CorrelationId);

public enum StageDisposition
{
    Continue,
    Rejected
}

public sealed record StageResult(
    StageDisposition Disposition,
    string Value);

public interface IStage
{
    Task<StageResult> ExecuteAsync(
        StageResult input,
        PipelineContext context,
        CancellationToken cancellationToken);
}

public sealed class PipelineRunner(
    IReadOnlyList<IStage> stages)
{
    public async Task<StageResult> ExecuteAsync(
        string input,
        PipelineContext context,
        CancellationToken cancellationToken)
    {
        StageResult current = new(
            StageDisposition.Continue,
            input);

        foreach (IStage stage in stages)
        {
            current = await stage.ExecuteAsync(
                current,
                context,
                cancellationToken);

            if (current.Disposition == StageDisposition.Rejected)
            {
                return current;
            }
        }

        return current;
    }
}

public sealed class ScriptedStage(
    string name,
    Func<StageResult, PipelineContext, CancellationToken, Task<StageResult>>
        behavior) : IStage
{
    public List<string> Calls { get; } = [];

    public async Task<StageResult> ExecuteAsync(
        StageResult input,
        PipelineContext context,
        CancellationToken cancellationToken)
    {
        Calls.Add(
            $"{name}:{context.CorrelationId}:{input.Value}");

        return await behavior(input, context, cancellationToken);
    }
}

public static class Check
{
    public static void True(bool condition, string message)
    {
        if (!condition)
        {
            throw new InvalidOperationException(message);
        }
    }

    public static void Equal<T>(
        T expected,
        T actual,
        string message)
    {
        if (!EqualityComparer<T>.Default.Equals(expected, actual))
        {
            throw new InvalidOperationException(
                $"{message} Expected: {expected}; actual: {actual}.");
        }
    }

    public static void SequenceEqual<T>(
        IReadOnlyList<T> expected,
        IReadOnlyList<T> actual,
        string message)
    {
        if (!expected.SequenceEqual(actual))
        {
            throw new InvalidOperationException(message);
        }
    }

    public static async Task<TException> ThrowsAsync<TException>(
        Func<Task> action,
        string message)
        where TException : Exception
    {
        try
        {
            await action();
        }
        catch (TException exception)
        {
            return exception;
        }

        throw new InvalidOperationException(message);
    }
}

ScriptedStage is not a mocking framework replacement. It is a controlled test double that records its inputs and returns exactly the outcome arranged by the test. That makes order and propagation visible.

Test Stage Success, Rejection, Exceptions, and Cancellation

A stage contract normally has four distinct outcomes:

  1. It succeeds and returns the expected output.
  2. It rejects an expected condition through the pipeline's documented result model.
  3. It throws an unexpected exception.
  4. It observes cooperative cancellation.

Do not collapse rejection and exception into one assertion. A rejected item is expected application behavior. An exception is a fault. They usually produce different short-circuit, telemetry, and retry behavior.

The next checks exercise those outcomes and the composition rules:

namespace PipelineTestingExample;

public static class PipelineChecks
{
    public static async Task RunAsync()
    {
        await SuccessPreservesOrderAndContextAsync();
        await RejectionShortCircuitsAsync();
        await ExceptionStopsLaterStagesAsync();
        await CancellationStopsControlledWorkAsync();
    }

    private static async Task SuccessPreservesOrderAndContextAsync()
    {
        ScriptedStage first = AppendStage("first", "-A");
        ScriptedStage second = AppendStage("second", "-B");
        PipelineRunner runner = new([first, second]);
        PipelineContext context = new("correlation-42");

        StageResult result = await runner.ExecuteAsync(
            "start",
            context,
            CancellationToken.None);

        Check.Equal(
            "start-A-B",
            result.Value,
            "The composed output must include both transformations.");
        Check.SequenceEqual(
            ["first:correlation-42:start"],
            first.Calls,
            "The first stage received unexpected data.");
        Check.SequenceEqual(
            ["second:correlation-42:start-A"],
            second.Calls,
            "The second stage did not receive the first output.");
    }

    private static async Task RejectionShortCircuitsAsync()
    {
        ScriptedStage reject = new(
            "reject",
            (input, _, _) => Task.FromResult(
                new StageResult(
                    StageDisposition.Rejected,
                    $"{input.Value}:rejected")));
        ScriptedStage unreachable = AppendStage("unreachable", "-bad");
        PipelineRunner runner = new([reject, unreachable]);

        StageResult result = await runner.ExecuteAsync(
            "start",
            new PipelineContext("correlation-43"),
            CancellationToken.None);

        Check.Equal(
            StageDisposition.Rejected,
            result.Disposition,
            "The rejection must be returned.");
        Check.Equal(
            0,
            unreachable.Calls.Count,
            "A rejected item must not reach later stages.");
    }

    private static async Task ExceptionStopsLaterStagesAsync()
    {
        InvalidOperationException expected = new("stage failed");
        ScriptedStage fault = new(
            "fault",
            (_, _, _) => Task.FromException<StageResult>(expected));
        ScriptedStage unreachable = AppendStage("unreachable", "-bad");
        PipelineRunner runner = new([fault, unreachable]);

        InvalidOperationException actual =
            await Check.ThrowsAsync<InvalidOperationException>(
            () => runner.ExecuteAsync(
                "start",
                new PipelineContext("correlation-44"),
                CancellationToken.None),
            "The original stage fault must propagate.");

        Check.True(
            ReferenceEquals(expected, actual),
            "The same exception instance must propagate.");
        Check.Equal(
            0,
            unreachable.Calls.Count,
            "A faulted item must not reach later stages.");
    }

    private static async Task CancellationStopsControlledWorkAsync()
    {
        TaskCompletionSource<bool> entered = NewGate();
        TaskCompletionSource<bool> release = NewGate();

        ScriptedStage controlled = new(
            "controlled",
            async (input, _, cancellationToken) =>
            {
                entered.TrySetResult(true);
                await release.Task.WaitAsync(cancellationToken);
                return input;
            });

        PipelineRunner runner = new([controlled]);
        using CancellationTokenSource cancellation = new();

        Task<StageResult> running = runner.ExecuteAsync(
            "start",
            new PipelineContext("correlation-45"),
            cancellation.Token);

        await entered.Task;
        await cancellation.CancelAsync();

        OperationCanceledException actual =
            await Check.ThrowsAsync<OperationCanceledException>(
                async () => await running,
                "Cancellation must end the controlled stage.");

        Check.Equal(
            cancellation.Token,
            actual.CancellationToken,
            "Cancellation must preserve the pipeline token.");
    }

    private static ScriptedStage AppendStage(
        string name,
        string suffix)
    {
        return new ScriptedStage(
            name,
            (input, _, cancellationToken) =>
            {
                cancellationToken.ThrowIfCancellationRequested();

                return Task.FromResult(
                    input with { Value = input.Value + suffix });
            });
    }

    private static TaskCompletionSource<bool> NewGate()
    {
        return new TaskCompletionSource<bool>(
            TaskCreationOptions.RunContinuationsAsynchronously);
    }
}

The cancellation test waits for the stage's entered signal before requesting cancellation. There is no guess about whether execution reached the boundary. TaskCompletionSource<T> supplies the producer side of a Task<T>, so the test controls completion and uses it as a gate rather than a clock.

The .NET cancellation model is cooperative. Therefore, a test should prove that the stage actually observes the token at the intended boundary. Merely passing a token is not enough.

Test Exceptions and Fault Propagation

Always await the task under test because task exceptions are surfaced when the task is awaited or otherwise observed. A test that starts pipeline work and then checks a side effect can finish before the task faults, leaving later work active after the assertion.

For each fault test, assert three things:

  • The expected exception type and, when important, the same exception instance or preserved details.
  • Later stages were not called.
  • Any owned worker or completion task reached a terminal state.

Do not catch every exception inside the test double and convert it to false. That changes the production contract the test is supposed to verify.

Test Cancellation at a Controlled Boundary

Cancellation-before-start and cancellation-during-work are different cases.

For cancellation before start, pass an already canceled token and verify that no side effect begins. For cancellation during work, wait for an explicit entered signal, cancel the token, and await the running task. If the implementation distinguishes caller cancellation from host shutdown, test each token source separately.

Avoid asserting that cancellation "undoes" earlier stages because .NET managed cancellation is cooperative and does not reverse completed side effects. A composition test should instead assert the last stage that completed and verify any documented cleanup or compensation behavior.

Test Bounded Capacity Without Timing Guesses

Bounded queues add a testable contract: when capacity is full, the next lossless write cannot complete until a reader frees space. The .NET Channels documentation current on 2026-08-10 documents BoundedChannelFullMode.Wait as the mode that waits for capacity.

You can test that contract by filling the queue, observing the pending write, and then either reading an item or canceling the pending operation:

using System.Threading.Channels;

namespace PipelineTestingExample;

public static class ChannelChecks
{
    public static async Task RunAsync()
    {
        await FullChannelBlocksUntilCapacityExistsAsync();
        await PendingWriteObservesCancellationAsync();
        await CompletionAllowsBufferedItemsToDrainAsync();
    }

    private static async Task FullChannelBlocksUntilCapacityExistsAsync()
    {
        Channel<int> channel = CreateChannel();
        await channel.Writer.WriteAsync(1);

        Task pendingWrite = channel.Writer.WriteAsync(2).AsTask();

        Check.True(
            !pendingWrite.IsCompleted,
            "The second write must wait while the channel is full.");

        int first = await channel.Reader.ReadAsync();
        await pendingWrite;
        int second = await channel.Reader.ReadAsync();

        Check.Equal(1, first, "The first item changed.");
        Check.Equal(2, second, "The pending item was not admitted.");
    }

    private static async Task PendingWriteObservesCancellationAsync()
    {
        Channel<int> channel = CreateChannel();
        await channel.Writer.WriteAsync(1);
        using CancellationTokenSource cancellation = new();

        Task pendingWrite = channel.Writer
            .WriteAsync(2, cancellation.Token)
            .AsTask();

        Check.True(
            !pendingWrite.IsCompleted,
            "The write must be pending before cancellation.");

        await cancellation.CancelAsync();

        OperationCanceledException actual =
            await Check.ThrowsAsync<OperationCanceledException>(
                async () => await pendingWrite,
                "The pending write must observe cancellation.");

        Check.Equal(
            cancellation.Token,
            actual.CancellationToken,
            "The pending write must preserve its cancellation token.");

        int buffered = await channel.Reader.ReadAsync();

        Check.Equal(
            1,
            buffered,
            "Canceling the pending write must preserve buffered data.");
        Check.True(
            channel.Writer.TryWrite(3),
            "Canceling one write must not complete the channel.");

        int next = await channel.Reader.ReadAsync();
        Check.Equal(
            3,
            next,
            "The channel must accept later writes.");
    }

    private static async Task CompletionAllowsBufferedItemsToDrainAsync()
    {
        Channel<int> channel = Channel.CreateBounded<int>(2);
        await channel.Writer.WriteAsync(1);
        await channel.Writer.WriteAsync(2);
        channel.Writer.Complete();

        List<int> drained = [];

        await foreach (int item in channel.Reader.ReadAllAsync())
        {
            drained.Add(item);
        }

        await channel.Reader.Completion;

        Check.SequenceEqual(
            [1, 2],
            drained,
            "Completion must preserve buffered items for draining.");
    }

    private static Channel<int> CreateChannel()
    {
        return Channel.CreateBounded<int>(
            new BoundedChannelOptions(1)
            {
                FullMode = BoundedChannelFullMode.Wait,
                SingleReader = true,
                SingleWriter = true
            });
    }
}

The test does not ask whether the write finished "within 50 milliseconds." It asserts the state transition caused by full capacity, then creates the event that allows progress.

Cancellation of a pending channel operation does not complete the channel, as documented by the .NET Channels cancellation and completion model. That is why the cancellation check verifies that the original buffered item still occupies capacity.

Test Completion, Drain, and Shutdown as Separate Events

The .NET Channels documentation distinguishes writer completion from the reader's ability to drain buffered items. The following terms are the testing vocabulary used in this article:

  • Completion means no more items will be admitted through an owned input boundary.
  • Drain means already accepted items are processed.
  • Shutdown means workers and terminal completion tasks have finished, and owned resources can be disposed.

A robust lifecycle test should stop admission, complete the input exactly once, release any controlled in-flight work, drain accepted items, await every worker, and then assert cleanup.

For an abort path, inject a deterministic fault. Verify that blocked producers or workers are released through the documented cancellation mechanism, every task is awaited, and the original fault remains observable. Never leave a background task running after the test returns.

Use Controlled Gates for In-Flight Work

Maximum in-flight work and result ordering require more than a list of final outputs. Instrument the test double with an atomic current count and maximum count. Each invocation should signal entry, wait on a controlled gate, then decrement in a finally block.

In this article's test arrangement, to prove a configured maximum of two concurrent items, start three items, await two entry signals, and assert that the third has not entered. Release one gate and verify that the third can then enter. This arrangement proves the configured limit through causality, not elapsed time.

For ordering, give each input a sequence number and deliberately release later items first. Then assert the contract actually promised by the implementation. If the design promises ordered publication, outputs must be reordered. If it promises only FIFO admission, out-of-order processing completion may be valid.

Test Maximum In-Flight Work as a State Machine

A maximum-concurrency test should model admission, active work, and release as observable states. The test double increments an atomic active counter on entry, updates a recorded maximum, signals that it entered, and then waits on a gate. Its finally block decrements the active counter so the test remains accurate when cancellation or an exception ends the operation.

Arrange more work items than the configured maximum. If the limit is two, start three. Wait until the first two controlled invocations report entry. At that point, assert that the third invocation has not reported entry. Release one active invocation, then await the third entry signal. This proves the limit and proves that queued work can progress after capacity becomes available.

That sequence is stronger than checking the maximum counter only after all work completes. A bug could temporarily admit too much work and later hide the evidence through a racy counter. Controlled entry signals let the test inspect the pipeline while it is saturated.

Use the same arrangement to test faults. Make one active item throw after release, then verify that the pipeline follows its documented policy: perhaps another independent item continues, or perhaps shared cancellation stops all workers. The test should not invent the policy. It should make the production policy observable.

Test Ordering at the Boundary That Promises It

"The pipeline is ordered" is too vague for a useful test. Order can refer to admission, stage entry, stage completion, output publication, or final persistence.

A single-reader queue can preserve dequeue order while downstream concurrent work completes out of order. A publication layer can then restore sequence before emitting results. Those are three different assertions. Test the one the API promises and name it precisely.

For ordered publication, assign sequence values 1, 2, and 3. Allow all three items to enter work, release 3 first, then 2, then 1. Verify that the observable output remains 1, 2, 3. For unordered publication, verify that every expected value appears exactly once without asserting a sequence the implementation never promised.

Also include duplicate and loss checks. A sorted result can look correct while containing the wrong multiplicity. Compare item identities or sequence values, not only transformed payload text.

Control Time Instead of Waiting for It

TimeProvider is the built-in .NET abstraction for current time, timestamps, and timers. Microsoft's TimeProvider overview specifically frames it as a way to make time-dependent code testable and predictable.

If the production code only checks a deadline, a small dependency-free provider is enough:

namespace PipelineTestingExample;

public sealed class ManualTimeProvider(
    DateTimeOffset initial) : TimeProvider
{
    private DateTimeOffset _utcNow = initial;

    public override DateTimeOffset GetUtcNow()
    {
        return _utcNow;
    }

    public void Advance(TimeSpan amount)
    {
        _utcNow += amount;
    }
}

public sealed class DeadlineStage(
    TimeProvider timeProvider)
{
    public bool CanStart(DateTimeOffset deadline)
    {
        return timeProvider.GetUtcNow() < deadline;
    }
}

public static class TimeChecks
{
    public static Task RunAsync()
    {
        DateTimeOffset start =
            new(2026, 9, 3, 13, 0, 0, TimeSpan.Zero);
        ManualTimeProvider time = new(start);
        DeadlineStage stage = new(time);
        DateTimeOffset deadline = start.AddMinutes(5);

        Check.True(
            stage.CanStart(deadline),
            "Work should start before the deadline.");

        time.Advance(TimeSpan.FromMinutes(5));

        Check.True(
            !stage.CanStart(deadline),
            "Work should not start at or after the deadline.");

        return Task.CompletedTask;
    }
}

This provider does not emulate timers, and it does not pretend to. Use a richer verified time-testing package only after pinning its exact stable version. The important design point is that code asks an injected clock instead of reading wall time directly.

Verify Cleanup and Ownership

Cleanup assertions belong in the test that owns the resource:

  • Dispose every CancellationTokenSource created by the test, as required by the .NET managed cancellation guidance.
  • Await every worker and completion task.
  • Dispose scopes and link handles after workers stop using them.
  • Verify that disposable payloads are disposed by the documented owner.
  • Use finally in test doubles so in-flight counters and gates are restored even when faults occur.

The container, pipeline, stage, and payload may have different owners. State those owners before writing assertions. Otherwise, a test can accidentally bless double disposal or hide a leaked resource.

Exercise Graceful and Forced Shutdown Separately

A graceful shutdown test begins with accepted work already inside the pipeline. Stop new admission, complete the owned input boundary, release controlled work, and await the terminal task. Assert that every accepted item reached its documented terminal outcome and that no new item was admitted after completion.

A forced shutdown test begins similarly but leaves one stage blocked on a cancellation-aware gate. Request shutdown, wait for the stage to observe cancellation, and await all worker tasks. Then verify the forced path did not report the blocked item as successfully completed.

If the production host has a grace period, inject the time boundary or expose a controlled signal. Do not wait for the real grace period. Advance a test clock for deadline decisions, or complete a timeout task from the test when the implementation accepts that abstraction.

Shutdown tests should also prove idempotence at the lifecycle boundary. A second completion request should follow the documented result rather than racing another successful completion owner. After shutdown, every task started by the test must be complete, canceled, or faulted and observed. "The assertions passed" is not enough if a worker remains alive.

Build a Small Deterministic Test Matrix

Once the primitives are controlled, a compact matrix provides broad coverage without duplicating arrangements:

Scenario Controlled event Primary assertion
Stage success Return a known result Exact output and context
Expected rejection Return a terminal rejection Later stages not called
Unexpected fault Complete with an exception Fault preserved and observed
Cancellation before work Supply a canceled token No side effect begins
Cancellation during work Cancel after entry signal Running task cancels
Full bounded input Fill capacity Additional write remains pending
Capacity released Read one item Pending write completes
Normal completion Complete writer Buffered items drain
Forced shutdown Cancel controlled worker Workers terminate and cleanup runs
Concurrent ordering Release items out of order Promised publication order holds

This matrix is a planning tool, not a single parameterized mega-test. Keep separate tests when the setup, ownership, or failure diagnosis differs.

Keep Correctness Tests Separate From Benchmarks

A unit test answers whether a contract holds. A benchmark measures behavior under a defined environment and workload. They should not share pass criteria.

BenchmarkDotNet's good practices require Release-mode measurement and warn against extrapolating results across environments. Do not assert that a pipeline processes an item under an arbitrary millisecond threshold in a unit test. Machine load, runtime warmup, and scheduling make that a flaky correctness signal.

Use deterministic tests for order, capacity, and completion. Use a separately configured benchmark or load test for throughput, latency distributions, allocations, and saturation.

Frequently Asked Questions

Should pipeline tests use Task.Delay?

Not as the primary synchronization mechanism. Signal stage entry with a controlled task, barrier, or equivalent primitive, then cause the next state transition explicitly.

How do I test a stage rejection?

Arrange an input that produces the documented rejection result, assert the rejection details, and verify that later stages were not called.

How do I prove cancellation happened during work?

Wait for an explicit entry signal from the stage, request cancellation, and await the running task. This proves the operation reached the controlled boundary before cancellation.

How do I test bounded-capacity blocking?

Fill the bounded queue, start another write, verify that it remains pending, then free capacity or cancel that specific write. No sleep is required.

Is channel completion the same as draining?

No. Under the .NET Channels completion model, writer completion stops new writes while buffered items can still be read. In this article's testing vocabulary, draining finishes accepted work, and shutdown additionally awaits workers and disposes resources.

Can a unit test prove throughput?

Deterministic unit tests can establish configured concurrency limits and ordering rules. Throughput requires a controlled benchmark or load test with a recorded environment and workload; BenchmarkDotNet good practices require Release-mode measurement and caution against extrapolating results across environments.

Build Tests Around Causality

When you unit test C# processing pipeline behavior, make each assertion follow from an event the test controls. Signal entry. Release work. Cancel the exact token. Fill the exact capacity. Complete the owned boundary. Await the terminal task.

That approach produces tests that are fast without being timing-sensitive. More importantly, it makes the pipeline's contract visible: which stages ran, what context they received, where processing stopped, what happened to accepted work, and who cleaned up after completion or failure.

How To Implement The Pipeline Design Pattern in C#

Learn about the pipeline design pattern in C#. Discover how to create and chain pipeline stages. Get code examples, tips, and use cases for this design pattern.

Pipeline Pattern in C#: A Modern .NET Guide

Learn the pipeline pattern in C#, its core stages, execution models, tradeoffs, and how to choose a stable .NET 10 implementation for production systems.

Testing Feature Slices in C#: Unit Tests, Integration Tests, and What to Test

Learn how to test feature slices in C# effectively. Covers unit tests for handlers, integration tests with WebApplicationFactory, and when to use each testing strategy.

An error has occurred. This application may no longer respond until reloaded. Reload