Skip to content

ReactorScenario

ReactorScenario<TReactor> is a lightweight, in-process test utility that lets you verify reactor side-effects without a running Chronicle server, gRPC transport, or observer registration.

It activates a fresh instance of TReactor from the provided service provider and routes events directly through the ReactorInvoker — the same execution path used in production.

In a Cratis Specification, the event delivered to the reactor is the when, and the side effect captured by a mock, fake, or test double is the then. The scenario keeps that test in-process while still using the production reactor invocation path.

End-to-end tests that require a live Chronicle server are accurate but slow. ReactorScenario<TReactor> drives the same reactor logic in-process so your specs remain fast and isolated without losing coverage of the handler logic.

ReactorScenario<TReactor> is in the Cratis.Chronicle.Testing NuGet package:

Terminal window
dotnet add package Cratis.Specifications.XUnit
dotnet add package Cratis.Chronicle.Testing
var scenario = new ReactorScenario<MyReactor>(serviceProvider);
await scenario.Given
.ForEventSource(someId)
.Events(new SomeEvent("value"), new SomeOtherEvent(42));
// Assert on side-effects captured by mocks in serviceProvider
myMock.Received(1).DoSomething("value");

Given is a fluent builder: call ForEventSource(id) to specify the event source, then Events(...) to route events through the reactor in order.

Pass an IServiceProvider to the constructor to supply mocks and stubs that the reactor depends on:

var orderRepository = Substitute.For<IOrderRepository>();
var services = new ServiceCollection()
.AddSingleton(orderRepository)
.BuildServiceProvider();
var scenario = new ReactorScenario<OrderNotificationReactor>(services);
await scenario.Given
.ForEventSource("order-123")
.Events(new OrderShipped("order-123", "FedEx"));
await orderRepository.Received(1).MarkAsShipped("order-123");

When no IServiceProvider is provided, ReactorScenario<TReactor> uses a DefaultServiceProvider that constructs the reactor via its default constructor.

[EventType("order-shipped")]
public record OrderShipped(string OrderId, string Carrier);
public class OrderNotificationReactor(IEmailService emailService) : IReactor
{
public async Task OnOrderShipped(OrderShipped @event, EventContext context)
{
await emailService.SendShippingConfirmation(@event.OrderId, @event.Carrier);
}
}
// In your spec:
public class when_order_is_shipped : Specification
{
readonly IEmailService _emailService = Substitute.For<IEmailService>();
ReactorScenario<OrderNotificationReactor> _scenario = default!;
void Establish()
{
var services = new ServiceCollection()
.AddSingleton(_emailService)
.BuildServiceProvider();
_scenario = new ReactorScenario<OrderNotificationReactor>(services);
}
Task Because() =>
_scenario.Given
.ForEventSource("order-123")
.Events(new OrderShipped("order-123", "DHL"));
[Fact] async Task should_send_shipping_confirmation() =>
await _emailService.Received(1).SendShippingConfirmation("order-123", "DHL");
}

Example: multiple events across event sources

Section titled “Example: multiple events across event sources”
var scenario = new ReactorScenario<TenantSyncReactor>(services);
// Events from two different tenants
await scenario.Given
.ForEventSource("tenant-A")
.Events(new TenantActivated("tenant-A"));
await scenario.Given
.ForEventSource("tenant-B")
.Events(new TenantActivated("tenant-B"));
// Both activations should have been handled
await syncService.Received(2).SyncTenant(Arg.Any<string>());
  • A fresh instance of TReactor is activated from the service provider for each Events(...) call, matching the production behavior where a new scope is created per event batch.
  • Event handling uses the same ReactorInvoker as in production, so convention-based method discovery (On<TEvent>) works identically.
  • The .Received() assertions in the examples come from NSubstitute. Any mocking framework works.