Skip to main content

Testing Guide

This guide covers testing patterns for Excalibur applications, from unit testing handlers to integration testing with real infrastructure.

Before You Start

  • .NET 8.0+ (or .NET 9/10 for latest features)
  • Install the required packages:
    dotnet add package Excalibur.Dispatch.Testing
    dotnet add package Excalibur.Testing
  • A test framework of your choice (xUnit, NUnit, or MSTest)
  • Familiarity with actions and handlers and the test harness

Testing Packages

PackagePurpose
Excalibur.TestingAggregate test fixtures, conformance test kits
Your test frameworkxUnit, NUnit, or MSTest (framework-agnostic)
Assertion libraryShouldly, FluentAssertions, or built-in
Mocking libraryFakeItEasy, Moq, or NSubstitute
dotnet add package Excalibur.Testing
dotnet add package xunit
dotnet add package Shouldly
dotnet add package FakeItEasy

Unit Testing Handlers

Action Handler Without Return Value

using FakeItEasy;
using Shouldly;
using Xunit;

public class CreateOrderHandlerTests
{
private readonly IOrderRepository _repository;
private readonly ILogger<CreateOrderHandler> _logger;
private readonly CreateOrderHandler _handler;

public CreateOrderHandlerTests()
{
_repository = A.Fake<IOrderRepository>();
_logger = A.Fake<ILogger<CreateOrderHandler>>();
_handler = new CreateOrderHandler(_repository, _logger);
}

[Fact]
public async Task HandleAsync_ValidAction_SavesOrder()
{
// Arrange
var action = new CreateOrderAction("customer-123", new List<string> { "item-1" });

// 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 HandleAsync_EmptyItems_ThrowsValidationException()
{
// Arrange
var action = new CreateOrderAction("customer-123", new List<string>());

// Act & Assert
await Should.ThrowAsync<ValidationException>(
() => _handler.HandleAsync(action, CancellationToken.None));
}
}

Action Handler With Return Value

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

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

[Fact]
public async Task HandleAsync_ExistingOrder_ReturnsOrder()
{
// Arrange
var orderId = Guid.NewGuid();
var expected = new Order { Id = orderId, CustomerId = "C1" };

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

var action = new GetOrderAction(orderId);

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

// Assert
result.ShouldBe(expected);
}

[Fact]
public async Task HandleAsync_NonExistentOrder_ThrowsNotFoundException()
{
// Arrange
var orderId = Guid.NewGuid();

A.CallTo(() => _repository.GetByIdAsync(orderId, A<CancellationToken>._))
.Returns((Order?)null);

var action = new GetOrderAction(orderId);

// Act & Assert
await Should.ThrowAsync<NotFoundException>(
() => _handler.HandleAsync(action, CancellationToken.None));
}
}

Testing Event-Sourced Aggregates

The Excalibur.Testing package provides a fluent Given-When-Then API for testing aggregates.

Basic Aggregate Testing

using Excalibur.Testing;
using Xunit;

public class OrderAggregateTests
{
[Fact]
public void Create_ValidOrder_RaisesOrderCreatedEvent()
{
new AggregateTestFixture<OrderAggregate>()
.When(order => order.Create("order-123", "customer-456", 99.99m))
.Then()
.ShouldRaise<OrderCreatedEvent>()
.StateShould(order => order.Id == "order-123")
.StateShould(order => order.Status == OrderStatus.Created);
}

[Fact]
public void Ship_CreatedOrder_RaisesOrderShippedEvent()
{
new AggregateTestFixture<OrderAggregate>()
.Given(new OrderCreatedEvent
{
AggregateId = "order-123",
CustomerId = "customer-456",
Amount = 99.99m
})
.When(order => order.Ship("tracking-789"))
.Then()
.ShouldRaise<OrderShippedEvent>(e => e.TrackingNumber == "tracking-789")
.StateShould(order => order.Status == OrderStatus.Shipped);
}

[Fact]
public void Ship_AlreadyShippedOrder_ThrowsInvalidOperationException()
{
new AggregateTestFixture<OrderAggregate>()
.Given(
new OrderCreatedEvent { AggregateId = "order-123" },
new OrderShippedEvent { AggregateId = "order-123" })
.When(order => order.Ship("tracking-999"))
.ShouldThrow<InvalidOperationException>("already shipped");
}

[Fact]
public void Cancel_ShippedOrder_RaisesNoEvents()
{
new AggregateTestFixture<OrderAggregate>()
.Given(
new OrderCreatedEvent { AggregateId = "order-123" },
new OrderShippedEvent { AggregateId = "order-123" })
.When(order => order.Cancel("customer request"))
.Then()
.ShouldRaiseNoEvents(); // Cannot cancel shipped orders
}
}

Testing Complex Aggregate State

[Fact]
public void AddItem_MultipleItems_CalculatesTotalCorrectly()
{
new AggregateTestFixture<OrderAggregate>()
.Given(new OrderCreatedEvent { AggregateId = "order-123" })
.When(order =>
{
order.AddItem("product-1", 2, 10.00m);
order.AddItem("product-2", 1, 25.00m);
})
.Then()
.ShouldRaise<OrderItemAddedEvent>()
.AssertAggregate(order =>
{
order.Items.Count.ShouldBe(2);
order.TotalAmount.ShouldBe(45.00m); // (2 * 10) + (1 * 25)
});
}

Integration Testing

Testing with WebApplicationFactory

using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using System.Net.Http.Json;

public class OrderApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;

public OrderApiTests(WebApplicationFactory<Program> factory)
{
_client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace real services with test doubles
services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
});
}).CreateClient();
}

[Fact]
public async Task CreateOrder_ValidRequest_ReturnsCreated()
{
// Arrange
var request = new { CustomerId = "C123", Items = new[] { "item-1" } };

// Act
var response = await _client.PostAsJsonAsync("/api/orders", request);

// Assert
response.StatusCode.ShouldBe(HttpStatusCode.Created);
}

[Fact]
public async Task GetOrder_ExistingOrder_ReturnsOk()
{
// Arrange - Create an order first
var createRequest = new { CustomerId = "C123", Items = new[] { "item-1" } };
var createResponse = await _client.PostAsJsonAsync("/api/orders", createRequest);
var location = createResponse.Headers.Location;

// Act
var response = await _client.GetAsync(location);

// Assert
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var order = await response.Content.ReadFromJsonAsync<OrderDto>();
order.ShouldNotBeNull();
order.CustomerId.ShouldBe("C123");
}

[Fact]
public async Task GetOrder_NonExistent_ReturnsNotFound()
{
// Act
var response = await _client.GetAsync($"/api/orders/{Guid.NewGuid()}");

// Assert
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
}

Testing with TestContainers

For integration tests that require real infrastructure:

using Testcontainers.MsSql;

public class SqlServerIntegrationTests : IAsyncLifetime
{
private readonly MsSqlContainer _sqlContainer;
private IServiceProvider _services = null!;

public SqlServerIntegrationTests()
{
_sqlContainer = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
}

public async Task InitializeAsync()
{
await _sqlContainer.StartAsync();

var services = new ServiceCollection();
services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(SqlServerIntegrationTests).Assembly);
});
services.AddSqlServerEventSourcing(_sqlContainer.GetConnectionString());

_services = services.BuildServiceProvider();

// Run migrations
await _services.GetRequiredService<IMigrator>().MigrateAsync(CancellationToken.None);
}

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

[Fact]
public async Task EventStore_AppendAndLoad_RoundTrips()
{
// Arrange
var eventStore = _services.GetRequiredService<IEventStore>();
var aggregateId = Guid.NewGuid().ToString();
var events = new[]
{
new OrderCreatedEvent { AggregateId = aggregateId, Version = 1 }
};

// Act
await eventStore.AppendAsync(aggregateId, events, 0, CancellationToken.None);
var loaded = await eventStore.LoadAsync(aggregateId, CancellationToken.None);

// Assert
loaded.ShouldHaveSingleItem();
loaded[0].ShouldBeOfType<OrderCreatedEvent>();
}
}

Conformance Testing

The Excalibur.Testing package includes conformance test kits for verifying custom implementations.

Event Store Conformance

using Excalibur.Testing.Conformance;

public class CustomEventStoreConformanceTests : EventStoreConformanceTestKit
{
protected override IEventStore CreateEventStore()
{
return new CustomEventStore(/* your configuration */);
}

// All conformance tests are inherited and run automatically
// Override specific tests if your implementation has special behavior
}

Snapshot Store Conformance

public class CustomSnapshotStoreConformanceTests : SnapshotStoreConformanceTestKit
{
protected override ISnapshotStore CreateSnapshotStore()
{
return new CustomSnapshotStore(/* your configuration */);
}
}

Available Conformance Test Kits

Test KitTests
EventStoreConformanceTestKitAppend, load, concurrency, versioning
SnapshotStoreConformanceTestKitSave, load, delete snapshots
OutboxStoreConformanceTestKitPublish, mark sent, cleanup
InboxStoreConformanceTestKitDeduplication, expiry
SagaStoreConformanceTestKitState persistence, timeout handling
LeaderElectionConformanceTestKitAcquire, renew, release leadership
DeadLetterStoreConformanceTestKitStore, retrieve, reprocess

Testing Middleware

Custom Middleware Unit Tests

public class LoggingMiddlewareTests
{
[Fact]
public async Task InvokeAsync_LogsBeforeAndAfter()
{
// Arrange
var logger = A.Fake<ILogger<LoggingMiddleware>>();
var middleware = new LoggingMiddleware(logger);
var message = A.Fake<IDispatchMessage>();
var context = new TestMessageContext();
var nextCalled = false;

ValueTask<IMessageResult> Next(
IDispatchMessage msg,
IMessageContext ctx,
CancellationToken ct)
{
nextCalled = true;
return new ValueTask<IMessageResult>(MessageResult.Success());
}

// Act
await middleware.InvokeAsync(message, context, Next, CancellationToken.None);

// Assert
nextCalled.ShouldBeTrue();
A.CallTo(logger).MustHaveHappenedTwiceExactly(); // Before and after
}
}

Testing Best Practices

Arrange-Act-Assert Pattern

[Fact]
public async Task ProcessPayment_ValidCard_ReturnsSuccess()
{
// Arrange - Set up test data and dependencies
var handler = new ProcessPaymentHandler(_paymentGateway);
var action = new ProcessPaymentAction(orderId, 99.99m, "USD");

// Act - Execute the behavior under test
var result = await handler.HandleAsync(action, CancellationToken.None);

// Assert - Verify the expected outcome
result.IsSuccess.ShouldBeTrue();
result.ReturnValue.TransactionId.ShouldNotBeNullOrEmpty();
}

Test Data Builders

public class OrderBuilder
{
private string _customerId = "default-customer";
private List<string> _items = new() { "default-item" };
private decimal _amount = 100m;

public OrderBuilder WithCustomer(string customerId)
{
_customerId = customerId;
return this;
}

public OrderBuilder WithItems(params string[] items)
{
_items = items.ToList();
return this;
}

public OrderBuilder WithAmount(decimal amount)
{
_amount = amount;
return this;
}

public CreateOrderAction BuildAction() =>
new(_customerId, _items);

public Order Build() =>
new()
{
Id = Guid.NewGuid(),
CustomerId = _customerId,
Items = _items,
TotalAmount = _amount
};
}

// Usage
[Fact]
public async Task Handler_HighValueOrder_RequiresApproval()
{
var action = new OrderBuilder()
.WithCustomer("VIP-001")
.WithAmount(10_000m)
.BuildAction();

// ...
}

Avoid Test Pollution

public class OrderHandlerTests : IDisposable
{
private readonly ServiceProvider _services;

public OrderHandlerTests()
{
var services = new ServiceCollection();
services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(OrderHandlerTests).Assembly);
});
services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
_services = services.BuildServiceProvider();
}

public void Dispose()
{
_services.Dispose();
}

[Fact]
public async Task Test1()
{
using var scope = _services.CreateScope();
var handler = scope.ServiceProvider.GetRequiredService<CreateOrderHandler>();
// Each test gets a fresh scope
}
}

Running Tests

# Run all tests
dotnet test

# Run specific test project
dotnet test tests/functional/Excalibur.Dispatch.Tests.Functional

# Run with coverage
dotnet test --collect:"XPlat Code Coverage"

# Run specific test
dotnet test --filter "FullyQualifiedName~CreateOrderHandler"

See Also

  • Test Harness — DispatchTestHarness for integration testing with real DI containers
  • Testing Handlers — Detailed patterns for unit testing action, event, and document handlers
  • Transport Test Doubles — InMemoryTransportSender, Receiver, and Subscriber for transport testing
  • Shouldly Assertions — Dispatch-specific Shouldly assertion extensions