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.
Why use ReactorScenario
Section titled “Why use ReactorScenario”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.
Installation
Section titled “Installation”ReactorScenario<TReactor> is in the Cratis.Chronicle.Testing NuGet package:
dotnet add package Cratis.Specifications.XUnitdotnet add package Cratis.Chronicle.TestingBasic usage
Section titled “Basic usage”using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;using Cratis.Chronicle.Testing.Reactors;using NSubstitute;
[EventType]public record TestingReactorScenarioSomeEvent(string Value);
[EventType]public record TestingReactorScenarioSomeOtherEvent(int Value);
public interface ITestingReactorScenarioService{ void DoSomething(string value);}
public class TestingReactorScenarioMyReactor(ITestingReactorScenarioService service) : IReactor{ public Task SomeEvent(TestingReactorScenarioSomeEvent @event, EventContext context) { service.DoSomething(@event.Value); return Task.CompletedTask; }}
public static class TestingReactorScenarioBasic{ public static async Task Run(IServiceProvider serviceProvider, ITestingReactorScenarioService myMock) { var someId = EventSourceId.New(); var scenario = new ReactorScenario<TestingReactorScenarioMyReactor>(serviceProvider); await scenario.Given .ForEventSource(someId) .Events(new TestingReactorScenarioSomeEvent("value"), new TestingReactorScenarioSomeOtherEvent(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.
Injecting dependencies
Section titled “Injecting dependencies”Pass an IServiceProvider to the constructor to supply mocks and stubs that the reactor depends on:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;using Cratis.Chronicle.Testing.Reactors;using Microsoft.Extensions.DependencyInjection;using NSubstitute;
[EventType]public record TestingReactorScenarioOrderShipped(string OrderId, string Carrier);
public interface ITestingReactorScenarioOrderRepository{ Task MarkAsShipped(string orderId);}
public class TestingReactorScenarioOrderReactor(ITestingReactorScenarioOrderRepository orderRepository) : IReactor{ public Task OrderShipped(TestingReactorScenarioOrderShipped @event, EventContext context) => orderRepository.MarkAsShipped(@event.OrderId);}
public static class TestingReactorScenarioInjectingDependencies{ public static async Task Run() { var orderRepository = Substitute.For<ITestingReactorScenarioOrderRepository>(); var services = new ServiceCollection() .AddSingleton(orderRepository) .BuildServiceProvider();
var scenario = new ReactorScenario<TestingReactorScenarioOrderReactor>(services); await scenario.Given .ForEventSource("order-123") .Events(new TestingReactorScenarioOrderShipped("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.
Example: email notification
Section titled “Example: email notification”using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;using Cratis.Chronicle.Testing.Reactors;using Cratis.Specifications;using Microsoft.Extensions.DependencyInjection;using NSubstitute;using Xunit;
[EventType("email-testing-order-shipped")]public record TestingReactorScenarioEmailOrderShipped(string OrderId, string Carrier);
public interface ITestingReactorScenarioEmailService{ Task SendShippingConfirmation(string orderId, string carrier);}
public class TestingReactorScenarioOrderNotificationReactor(ITestingReactorScenarioEmailService emailService) : IReactor{ public async Task OnOrderShipped(TestingReactorScenarioEmailOrderShipped @event, EventContext context) { await emailService.SendShippingConfirmation(@event.OrderId, @event.Carrier); }}
public class when_order_is_shipped : Specification{ readonly ITestingReactorScenarioEmailService _emailService = Substitute.For<ITestingReactorScenarioEmailService>(); ReactorScenario<TestingReactorScenarioOrderNotificationReactor> _scenario = default!;
void Establish() { var services = new ServiceCollection() .AddSingleton(_emailService) .BuildServiceProvider();
_scenario = new ReactorScenario<TestingReactorScenarioOrderNotificationReactor>(services); }
Task Because() => _scenario.Given .ForEventSource("order-123") .Events(new TestingReactorScenarioEmailOrderShipped("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”using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;using Cratis.Chronicle.Testing.Reactors;using Microsoft.Extensions.DependencyInjection;using NSubstitute;
[EventType]public record TestingReactorScenarioTenantActivated(string TenantId);
public interface ITestingReactorScenarioSyncService{ Task SyncTenant(string tenantId);}
public class TestingReactorScenarioTenantSyncReactor(ITestingReactorScenarioSyncService syncService) : IReactor{ public Task TenantActivated(TestingReactorScenarioTenantActivated @event, EventContext context) => syncService.SyncTenant(@event.TenantId);}
public static class TestingReactorScenarioMultipleSources{ public static async Task Run() { var syncService = Substitute.For<ITestingReactorScenarioSyncService>(); var services = new ServiceCollection() .AddSingleton(syncService) .BuildServiceProvider();
var scenario = new ReactorScenario<TestingReactorScenarioTenantSyncReactor>(services);
// Events from two different tenants await scenario.Given .ForEventSource("tenant-A") .Events(new TestingReactorScenarioTenantActivated("tenant-A"));
await scenario.Given .ForEventSource("tenant-B") .Events(new TestingReactorScenarioTenantActivated("tenant-B"));
// Both activations should have been handled await syncService.Received(2).SyncTenant(Arg.Any<string>()); }}- A fresh instance of
TReactoris activated from the service provider for eachEvents(...)call, matching the production behavior where a new scope is created per event batch. - Event handling uses the same
ReactorInvokeras in production, so convention-based method discovery (On<TEvent>) works identically. - The
.Received()assertions in the examples come from NSubstitute. Any mocking framework works.