Skip to main content

Testing Dispatch Handlers

You have handlers that process actions and events. You have middleware that validates, authorizes, and retries. You need to know they work before deploying to production.

This guide covers the three levels of testing you will use most often: unit testing individual handlers, testing middleware behavior, and integration testing the full pipeline. It uses the same stack the Dispatch test suite itself runs on: xUnit, Shouldly, and FakeItEasy.

Before You Start

  • .NET 8.0+ (or .NET 9/10 for latest features)
  • Install the testing packages:
    dotnet add package xunit
    dotnet add package Shouldly
    dotnet add package FakeItEasy
    dotnet add package Excalibur.Dispatch.Testing # test harness and utilities
    dotnet add package Excalibur.Dispatch.Testing.Shouldly # fluent assertions (optional)
  • Familiarity with actions and handlers
  • For pipeline testing, see also the Test Harness guide

Test Stack Setup

Add the testing packages to your test project:

dotnet add package xunit
dotnet add package Shouldly
dotnet add package FakeItEasy
dotnet add package Microsoft.NET.Test.Sdk

A GlobalUsings.cs file keeps your test files clean:

global using Xunit;
global using Shouldly;
global using FakeItEasy;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Logging;

Unit Testing a Handler

A handler is a class with dependencies and a HandleAsync method. Test it like any other class: mock the dependencies, call the method, assert the result.

Action Handler (Command)

public record CreateOrderAction(string CustomerId, List<string> Items) : IDispatchAction;

public class CreateOrderHandler : IActionHandler<CreateOrderAction>
{
private readonly IOrderRepository _repository;
private readonly ILogger<CreateOrderHandler> _logger;

public CreateOrderHandler(IOrderRepository repository, ILogger<CreateOrderHandler> logger)
{
_repository = repository;
_logger = logger;
}

public async Task HandleAsync(CreateOrderAction action, CancellationToken cancellationToken)
{
var order = new Order(action.CustomerId, action.Items);
await _repository.SaveAsync(order, cancellationToken);
}
}

Test it:

public class CreateOrderHandlerShould
{
private readonly IOrderRepository _repository;
private readonly CreateOrderHandler _handler;

public CreateOrderHandlerShould()
{
_repository = A.Fake<IOrderRepository>();
var logger = A.Fake<ILogger<CreateOrderHandler>>();
_handler = new CreateOrderHandler(_repository, logger);
}

[Fact]
public async Task Save_new_order_to_repository()
{
// Arrange
var action = new CreateOrderAction("customer-123", ["item-a", "item-b"]);

// Act
await _handler.HandleAsync(action, CancellationToken.None);

// Assert
A.CallTo(() => _repository.SaveAsync(
A<Order>.That.Matches(o => o.CustomerId == "customer-123"),
A<CancellationToken>._))
.MustHaveHappenedOnceExactly();
}

[Fact]
public async Task Pass_cancellation_token_to_repository()
{
// Arrange
using var cts = new CancellationTokenSource();
var token = cts.Token;
var action = new CreateOrderAction("customer-123", ["item-a"]);

// Act
await _handler.HandleAsync(action, token);

// Assert
A.CallTo(() => _repository.SaveAsync(
A<Order>._,
token))
.MustHaveHappened();
}
}

Action Handler (Query)

public record GetOrderAction(Guid OrderId) : IDispatchAction<Order>;

public class GetOrderHandler : IActionHandler<GetOrderAction, Order>
{
private readonly IOrderRepository _repository;

public GetOrderHandler(IOrderRepository repository) => _repository = repository;

public async Task<Order> HandleAsync(
GetOrderAction action, CancellationToken cancellationToken)
{
return await _repository.GetByIdAsync(action.OrderId, cancellationToken)
?? throw new OrderNotFoundException(action.OrderId);
}
}

Test both the happy path and the error case:

public class GetOrderHandlerShould
{
private readonly IOrderRepository _repository;
private readonly GetOrderHandler _handler;

public GetOrderHandlerShould()
{
_repository = A.Fake<IOrderRepository>();
_handler = new GetOrderHandler(_repository);
}

[Fact]
public async Task Return_order_when_found()
{
// Arrange
var orderId = Guid.NewGuid();
var expected = new Order { Id = orderId, CustomerId = "C1" };
A.CallTo(() => _repository.GetByIdAsync(orderId, A<CancellationToken>._))
.Returns(expected);

// Act
var result = await _handler.HandleAsync(
new GetOrderAction(orderId), CancellationToken.None);

// Assert
result.ShouldBe(expected);
}

[Fact]
public async Task Throw_when_order_not_found()
{
// Arrange
var orderId = Guid.NewGuid();
A.CallTo(() => _repository.GetByIdAsync(orderId, A<CancellationToken>._))
.Returns((Order?)null);

// Act & Assert
await Should.ThrowAsync<OrderNotFoundException>(
_handler.HandleAsync(new GetOrderAction(orderId), CancellationToken.None));
}
}

Event Handler

Event handlers follow the same pattern. The difference is that events are broadcast to multiple handlers, so you test each handler in isolation:

public record OrderCreatedEvent(Guid OrderId, string CustomerId) : DomainEventBase;

public class SendOrderConfirmationHandler : IEventHandler<OrderCreatedEvent>
{
private readonly IEmailService _emailService;

public SendOrderConfirmationHandler(IEmailService emailService)
=> _emailService = emailService;

public async Task HandleAsync(
OrderCreatedEvent @event, CancellationToken cancellationToken)
{
await _emailService.SendOrderConfirmationAsync(
@event.OrderId, @event.CustomerId, cancellationToken);
}
}

public class SendOrderConfirmationHandlerShould
{
[Fact]
public async Task Send_confirmation_email_with_order_details()
{
// Arrange
var emailService = A.Fake<IEmailService>();
var handler = new SendOrderConfirmationHandler(emailService);
var orderId = Guid.NewGuid();

// Act
await handler.HandleAsync(
new OrderCreatedEvent(orderId, "customer-123"),
CancellationToken.None);

// Assert
A.CallTo(() => emailService.SendOrderConfirmationAsync(
orderId, "customer-123", A<CancellationToken>._))
.MustHaveHappenedOnceExactly();
}
}

Testing Middleware Behavior

Middleware wraps the pipeline and can short-circuit execution, modify context, or add cross-cutting behavior. Testing middleware requires simulating the pipeline by providing a next delegate.

Validation Short-Circuit

When validation fails, the middleware should prevent the handler from running:

public class ValidationMiddlewareShould
{
private readonly IValidationService _validationService;
private bool _handlerCalled;

public ValidationMiddlewareShould()
{
_validationService = A.Fake<IValidationService>();
_handlerCalled = false;
}

// The "next" delegate simulates the rest of the pipeline
private ValueTask<IMessageResult> NextDelegate(
IDispatchMessage message, IMessageContext context, CancellationToken ct)
{
_handlerCalled = true;
return new ValueTask<IMessageResult>(MessageResult.Success());
}

[Fact]
public async Task Skip_handler_when_validation_fails()
{
// Arrange
A.CallTo(() => _validationService.ValidateAsync(
A<object>._, A<MessageValidationContext>._, A<CancellationToken>._))
.Returns(ValidationResult.Failure(
new ValidationError("Amount", "Amount must be positive")));

var options = Microsoft.Extensions.Options.Options.Create(
new ValidationOptions { Enabled = true, UseCustomValidation = true });
var middleware = new ValidationMiddleware(
options, _validationService, A.Fake<ILogger<ValidationMiddleware>>());
var message = A.Fake<IDispatchMessage>();
var context = A.Fake<IMessageContext>();

// Act & Assert
await Should.ThrowAsync<ValidationException>(
middleware.InvokeAsync(
message, context, NextDelegate, CancellationToken.None).AsTask());

_handlerCalled.ShouldBeFalse("Handler should not run when validation fails");
}

[Fact]
public async Task Call_handler_when_validation_passes()
{
// Arrange
A.CallTo(() => _validationService.ValidateAsync(
A<object>._, A<MessageValidationContext>._, A<CancellationToken>._))
.Returns(ValidationResult.Success());

var options = Microsoft.Extensions.Options.Options.Create(
new ValidationOptions { Enabled = true, UseCustomValidation = true });
var middleware = new ValidationMiddleware(
options, _validationService, A.Fake<ILogger<ValidationMiddleware>>());
var message = A.Fake<IDispatchMessage>();
var context = A.Fake<IMessageContext>();

// Act
var result = await middleware.InvokeAsync(
message, context, NextDelegate, CancellationToken.None);

// Assert
_handlerCalled.ShouldBeTrue();
result.IsSuccess.ShouldBeTrue();
}
}

Testing Middleware Execution Order

When you compose multiple middleware, you need to verify they run in the correct order:

public class MiddlewareOrderShould
{
[Fact]
public async Task Execute_middleware_in_registration_order()
{
// Arrange
var executionOrder = new List<string>();

var middleware1 = new TrackingMiddleware("Auth", executionOrder);
var middleware2 = new TrackingMiddleware("Validation", executionOrder);
var middleware3 = new TrackingMiddleware("Logging", executionOrder);

ValueTask<IMessageResult> handler(
IDispatchMessage msg, IMessageContext ctx, CancellationToken ct)
{
executionOrder.Add("Handler");
return new ValueTask<IMessageResult>(MessageResult.Success());
}

// Act -- chain middleware manually to simulate the pipeline
await middleware1.InvokeAsync(
A.Fake<IDispatchMessage>(),
A.Fake<IMessageContext>(),
(m1, c1, t1) => middleware2.InvokeAsync(
m1, c1,
(m2, c2, t2) => middleware3.InvokeAsync(m2, c2, handler, t2),
t1),
CancellationToken.None);

// Assert
executionOrder.ShouldBe(["Auth", "Validation", "Logging", "Handler"]);
}

// Simple middleware that records when it runs
private sealed class TrackingMiddleware(string name, List<string> order)
{
public ValueTask<IMessageResult> InvokeAsync(
IDispatchMessage message,
IMessageContext context,
DispatchRequestDelegate next,
CancellationToken cancellationToken)
{
order.Add(name);
return next(message, context, cancellationToken);
}
}
}

Testing with Message Context

Many handlers and middleware need values from IMessageContext -- the user ID, tenant ID, or custom items set by earlier middleware. You have two options for providing context in tests.

Option 1: Fake with FakeItEasy

For simple cases, fake only the properties you need:

var context = A.Fake<IMessageContext>();
A.CallTo(() => context.MessageId).Returns("msg-001");
A.CallTo(() => context.UserId).Returns("user-123");
A.CallTo(() => context.TenantId).Returns("tenant-456");

Option 2: Concrete Test Double

For tests that read and write context items, a concrete implementation is simpler:

public sealed class TestMessageContext : IMessageContext
{
private readonly Dictionary<string, object> _items = [];

public string? MessageId { get; set; } = Guid.NewGuid().ToString();
public string? UserId { get; set; }
public string? TenantId { get; set; }
public IDictionary<string, object> Items => _items;

public T? GetItem<T>(string key) =>
_items.TryGetValue(key, out var value) && value is T typed ? typed : default;

public void SetItem<T>(string key, T value) => _items[key] = value!;
}

Use it when your handler reads context items set by middleware:

[Fact]
public async Task Use_tenant_from_context()
{
var context = new TestMessageContext { TenantId = "acme-corp" };
context.SetItem("CorrelationId", "corr-123");

// Pass context to your handler or middleware under test
await handler.HandleAsync(action, context, CancellationToken.None);

// Assert the handler used the correct tenant
A.CallTo(() => _repository.SaveAsync(
A<Order>.That.Matches(o => o.TenantId == "acme-corp"),
A<CancellationToken>._))
.MustHaveHappened();
}

Integration Testing the Pipeline

Unit tests verify individual handlers and middleware. Integration tests verify they work together through the real Dispatch pipeline.

Setting Up a Test Host

Register Dispatch with the in-memory transport, add your handlers and middleware, then dispatch a message and verify the result:

public class OrderPipelineShould : IAsyncLifetime
{
private ServiceProvider _provider = null!;

public async Task InitializeAsync()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(CreateOrderHandler).Assembly);
dispatch.UseValidation();
dispatch.UseOpenTelemetry();
});

// Register your dependencies
services.AddSingleton(A.Fake<IOrderRepository>());
services.AddSingleton(A.Fake<IEmailService>());

_provider = services.BuildServiceProvider();
await Task.CompletedTask;
}

public async Task DisposeAsync()
{
await _provider.DisposeAsync();
}

[Fact]
public async Task Dispatch_action_through_full_pipeline()
{
// Arrange
var dispatcher = _provider.GetRequiredService<IDispatcher>();
var repository = _provider.GetRequiredService<IOrderRepository>();

// Act
await dispatcher.DispatchAsync(
new CreateOrderAction("customer-123", ["item-a"]),
CancellationToken.None);

// Assert -- handler was invoked through the pipeline
A.CallTo(() => repository.SaveAsync(
A<Order>.That.Matches(o => o.CustomerId == "customer-123"),
A<CancellationToken>._))
.MustHaveHappenedOnceExactly();
}

[Fact]
public async Task Return_result_from_query_handler()
{
// Arrange
var dispatcher = _provider.GetRequiredService<IDispatcher>();
var repository = _provider.GetRequiredService<IOrderRepository>();
var orderId = Guid.NewGuid();
var expected = new Order { Id = orderId, CustomerId = "C1" };

A.CallTo(() => repository.GetByIdAsync(orderId, A<CancellationToken>._))
.Returns(expected);

// Act
var result = await dispatcher.DispatchAsync(
new GetOrderAction(orderId),
CancellationToken.None);

// Assert
result.ShouldBe(expected);
}
}

Testing Middleware Rejects Invalid Messages

[Fact]
public async Task Reject_invalid_action_before_handler_runs()
{
// Arrange
var dispatcher = _provider.GetRequiredService<IDispatcher>();
var repository = _provider.GetRequiredService<IOrderRepository>();

// Action with invalid data (empty customer ID)
var invalidAction = new CreateOrderAction("", []);

// Act & Assert
await Should.ThrowAsync<ValidationException>(
dispatcher.DispatchAsync(invalidAction, CancellationToken.None));

// Handler should never have been called
A.CallTo(() => repository.SaveAsync(A<Order>._, A<CancellationToken>._))
.MustNotHaveHappened();
}

Testing Custom Middleware

If you write your own middleware, test that it correctly calls next (or doesn't) and that it modifies context or results as expected.

public class CorrelationIdMiddleware : IDispatchMiddleware
{
public DispatchMiddlewareStage? Stage => DispatchMiddlewareStage.Start;

public ValueTask<IMessageResult> InvokeAsync(
IDispatchMessage message,
IMessageContext context,
DispatchRequestDelegate next,
CancellationToken cancellationToken)
{
if (context.GetItem<string>("CorrelationId") is null)
{
context.SetItem("CorrelationId", Guid.NewGuid().ToString());
}

return next(message, context, cancellationToken);
}
}

public class CorrelationIdMiddlewareShould
{
[Fact]
public async Task Add_correlation_id_when_missing()
{
// Arrange
var middleware = new CorrelationIdMiddleware();
var context = new TestMessageContext();

// Act
await middleware.InvokeAsync(
A.Fake<IDispatchMessage>(),
context,
(msg, ctx, ct) => new ValueTask<IMessageResult>(MessageResult.Success()),
CancellationToken.None);

// Assert
context.GetItem<string>("CorrelationId").ShouldNotBeNullOrEmpty();
}

[Fact]
public async Task Preserve_existing_correlation_id()
{
// Arrange
var middleware = new CorrelationIdMiddleware();
var context = new TestMessageContext();
context.SetItem("CorrelationId", "existing-id");

// Act
await middleware.InvokeAsync(
A.Fake<IDispatchMessage>(),
context,
(msg, ctx, ct) => new ValueTask<IMessageResult>(MessageResult.Success()),
CancellationToken.None);

// Assert
context.GetItem<string>("CorrelationId").ShouldBe("existing-id");
}

[Fact]
public async Task Call_next_delegate()
{
// Arrange
var middleware = new CorrelationIdMiddleware();
var nextCalled = false;

// Act
await middleware.InvokeAsync(
A.Fake<IDispatchMessage>(),
new TestMessageContext(),
(msg, ctx, ct) =>
{
nextCalled = true;
return new ValueTask<IMessageResult>(MessageResult.Success());
},
CancellationToken.None);

// Assert
nextCalled.ShouldBeTrue();
}
}

Common Patterns

Capturing Arguments

Use FakeItEasy's Invokes to capture arguments for detailed assertions:

Order? savedOrder = null;
A.CallTo(() => _repository.SaveAsync(A<Order>._, A<CancellationToken>._))
.Invokes((Order order, CancellationToken _) => savedOrder = order);

await _handler.HandleAsync(action, CancellationToken.None);

savedOrder.ShouldNotBeNull();
savedOrder!.CustomerId.ShouldBe("customer-123");
savedOrder.Items.Count.ShouldBe(2);

Testing Async Exceptions

ValueTask methods need .AsTask() for Shouldly's ThrowAsync:

await Should.ThrowAsync<ValidationException>(
middleware.InvokeAsync(message, context, next, ct).AsTask());

Verifying No Side Effects

After a test that should short-circuit, verify downstream services were not called:

A.CallTo(() => _emailService.SendOrderConfirmationAsync(
A<Guid>._, A<string>._, A<CancellationToken>._))
.MustNotHaveHappened();

Handling CancellationToken

Always verify your handlers respect cancellation:

[Fact]
public async Task Respect_cancellation()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();

A.CallTo(() => _repository.SaveAsync(A<Order>._, A<CancellationToken>._))
.ThrowsAsync(new OperationCanceledException());

// Act & Assert
await Should.ThrowAsync<OperationCanceledException>(
_handler.HandleAsync(
new CreateOrderAction("C1", ["item"]),
cts.Token));
}

Test Organization

Organize tests to mirror your source structure:

tests/
├── YourApp.Tests/
│ ├── GlobalUsings.cs
│ ├── Handlers/
│ │ ├── CreateOrderHandlerShould.cs
│ │ ├── GetOrderHandlerShould.cs
│ │ └── SendOrderConfirmationHandlerShould.cs
│ ├── Middleware/
│ │ ├── CorrelationIdMiddlewareShould.cs
│ │ └── TenantMiddlewareShould.cs
│ ├── Pipeline/
│ │ └── OrderPipelineShould.cs
│ └── TestDoubles/
│ └── TestMessageContext.cs

Naming convention: {ClassUnderTest}Should with test methods named as plain-English sentences: Save_new_order_to_repository, Throw_when_order_not_found.

Next Steps

See Also