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.

A reactor that returns its side effects — the command or event that should follow — is a pure function of (event, read model). Test that logic by constructing the reactor and calling the handler directly: no Chronicle server, no service provider, no mocks.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reactors;
using Cratis.Specifications;
using Xunit;
[EventType("pure-function-vibe-cancelled")]
public record TestingPureVibeCancelled();
public record TestingPureCreateNotification(string Host);
public record TestingPureVibeAttendees(string Host);
// The reactor returns the command as a side effect, so its logic is a pure
// function of (event, read model) — no IEventLog or ICommandPipeline injected.
public class TestingPureCancellationReactor : IReactor
{
public Task<TestingPureCreateNotification> VibeCancelled(
TestingPureVibeCancelled @event,
TestingPureVibeAttendees attendees) =>
Task.FromResult(new TestingPureCreateNotification(attendees.Host));
}
public class when_a_vibe_is_cancelled : Specification
{
readonly TestingPureCancellationReactor _reactor = new();
readonly TestingPureVibeAttendees _attendees = new("Ada");
TestingPureCreateNotification _command = default!;
async Task Because() => _command = await _reactor.VibeCancelled(new TestingPureVibeCancelled(), _attendees);
[Fact] void should_request_a_notification_for_the_host() => _command.Host.ShouldEqual("Ada");
}

Keep these as your default. They are the fastest specs, and because they assert your reactor’s contract rather than Chronicle’s internals, they keep passing as the framework evolves. A reactor that instead injects IEventLog/ICommandPipeline and acts in the method body cannot be tested this way — returning side effects is what makes the pure-function test possible.

Reach for ReactorScenario to prove the wiring

Section titled “Reach for ReactorScenario to prove the wiring”

Some behavior is the framework’s contract, not your logic, and a direct call cannot reach it:

  • Chronicle materializes the right read model for a handler-method parameter (including a custom ICanResolveReadModelKey);
  • a constructor or method-parameter dependency resolves as expected;
  • the right handler dispatches for a given event type;
  • the produced side effect flows through the real invoker.

For those, ReactorScenario<TReactor> drives your reactor through the same in-process invocation path as production — accurate, but without a live Chronicle server, so specs stay fast. Its ShouldHaveProduced<T> assertion uses the same “then” as the pure-function test, so a spec graduates from one to the other without relearning how to assert.

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

Terminal window
dotnet add package Cratis.Specifications.XUnit
dotnet add package Cratis.Chronicle.Testing
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.

Register the mocks and stubs the reactor depends on through the scenario’s Services collection — the same IServiceCollection you use to configure an application. Logging is registered for you, so a reactor that injects ILogger<T> activates without any extra setup:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reactors;
using Cratis.Chronicle.Testing.Reactors;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
[EventType]
public record TestingReactorScenarioServicesBookingCancelled(string BookingId);
public interface ITestingReactorScenarioServicesNotifications
{
Task Notify(string message);
}
public class TestingReactorScenarioServicesCancellationReactor(ITestingReactorScenarioServicesNotifications notifications) : IReactor
{
public Task BookingCancelled(TestingReactorScenarioServicesBookingCancelled @event, EventContext context) =>
notifications.Notify($"Booking {@event.BookingId} was cancelled.");
}
public static class TestingReactorScenarioServices
{
public static async Task Run()
{
var notifications = Substitute.For<ITestingReactorScenarioServicesNotifications>();
var scenario = new ReactorScenario<TestingReactorScenarioServicesCancellationReactor>();
scenario.Services.AddSingleton(notifications);
await scenario.Given
.ForEventSource("booking-123")
.Events(new TestingReactorScenarioServicesBookingCancelled("booking-123"));
await notifications.Received(1).Notify("Booking booking-123 was cancelled.");
}
}

Services resolves both the reactor’s constructor dependencies and any service-typed handler-method parameters. If a dependency is missing, the scenario throws CannotActivateReactorForScenario, naming the type it could not resolve.

You can also pass a pre-built IServiceProvider to the constructor when you need full control over registration:

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 neither is used, the scenario builds a provider from Services (with logging registered).

A reactor can take a read model as a handler-method parameter, which Chronicle materializes for the event source when the reactor runs. Seed the expected read-model state with Given.ForEventSourceId(id).ReadModel(...), then drive the triggering event:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
using Cratis.Chronicle.Reactors;
using Cratis.Chronicle.ReadModels;
using Cratis.Chronicle.Testing.Reactors;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
[EventType]
public record TestingReactorScenarioVibeCancelled();
[EventType]
public record TestingReactorScenarioVibeStarted(string Host);
[Passive]
[FromEvent<TestingReactorScenarioVibeStarted>]
public record TestingReactorScenarioVibeAttendees([Key] string Id, string Host);
public interface ITestingReactorScenarioNotifier
{
Task Notify(string host);
}
public class TestingReactorScenarioCancellationReactor(ITestingReactorScenarioNotifier notifier) : IReactor
{
// The read model is a handler-method parameter — Chronicle materializes it for the vibe.
public Task VibeCancelled(
TestingReactorScenarioVibeCancelled @event,
EventContext context,
TestingReactorScenarioVibeAttendees attendees) =>
notifier.Notify(attendees.Host);
}
public static class TestingReactorScenarioReadModel
{
public static async Task Run()
{
var notifier = Substitute.For<ITestingReactorScenarioNotifier>();
var vibeId = EventSourceId.New();
var scenario = new ReactorScenario<TestingReactorScenarioCancellationReactor>();
scenario.Services.AddSingleton(notifier);
// Seed the read-model parameter, then drive the triggering event.
scenario.Given.ForEventSourceId(vibeId).ReadModel(new TestingReactorScenarioVibeAttendees(vibeId, "Ada"));
await scenario.Given
.ForEventSource(vibeId)
.Events(new TestingReactorScenarioVibeCancelled());
await notifier.Received(1).Notify("Ada");
}
}

When a reactor returns a side effect — an event or a command — assert it directly with ShouldHaveProduced<T>, with no mocks or event-store wiring:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reactors;
using Cratis.Chronicle.Testing.Reactors;
[EventType]
public record TestingReactorScenarioReminderVibeStarted(string Host);
public record TestingReactorScenarioSendReminder(string Host);
public class TestingReactorScenarioReminderReactor : IReactor
{
public Task<TestingReactorScenarioSendReminder> VibeStarted(TestingReactorScenarioReminderVibeStarted @event) =>
Task.FromResult(new TestingReactorScenarioSendReminder(@event.Host));
}
public static class TestingReactorScenarioProducedSideEffects
{
public static async Task Run()
{
var vibeId = EventSourceId.New();
var scenario = new ReactorScenario<TestingReactorScenarioReminderReactor>();
await scenario.Given
.ForEventSource(vibeId)
.Events(new TestingReactorScenarioReminderVibeStarted("Ada"));
scenario.ShouldHaveProduced<TestingReactorScenarioSendReminder>(reminder => reminder.Host == "Ada");
}
}

The scenario records everything the reactor returns — flattening collections and unwrapping cross-stream event wrappers — so ShouldHaveProduced<T>(), its predicate overload, and ShouldNotHaveProduced<T>() all work against events and commands alike. The raw list is available on Produced.

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 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 dispatch — where each handler method is matched by its event parameter type — works identically.
  • The .Received() assertions in the examples come from NSubstitute. Any mocking framework works.