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");
}

View C# snippet source on GitHub

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");
}
}

View C# snippet source on GitHub

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

When the reactor reads stream metadata from EventContext, supply that metadata with OnStream(...) before Events(...):

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reactors;
using Cratis.Chronicle.Testing.Reactors;
[EventType]
public record TestingReactorScenarioRoutedPaymentReceived(decimal Amount);
[EventType]
public record TestingReactorScenarioPaymentRouteObserved(decimal Amount, string SourceType, string StreamType, string StreamId);
public class TestingReactorScenarioRouteReactor : IReactor
{
public Task<TestingReactorScenarioPaymentRouteObserved> Handle(
TestingReactorScenarioRoutedPaymentReceived @event, EventContext context) =>
Task.FromResult(new TestingReactorScenarioPaymentRouteObserved(
@event.Amount, context.EventSourceType.Value, context.EventStreamType.Value, context.EventStreamId.Value));
}
public static class TestingReactorScenarioRoutedEvents
{
public static async Task Run()
{
using var scenario = new ReactorScenario<TestingReactorScenarioRouteReactor>();
var orderId = EventSourceId.New();
await scenario.Given.ForEventSource(orderId)
.OnStream(eventSourceType: "Order", eventStreamType: "Payments", eventStreamId: "monthly")
.Events(new TestingReactorScenarioRoutedPaymentReceived(100));
scenario.ShouldHaveProduced<TestingReactorScenarioPaymentRouteObserved>(observed =>
observed.Amount == 100 && observed.SourceType == "Order" &&
observed.StreamType == "Payments" && observed.StreamId == "monthly");
}
}

View C# snippet source on GitHub

The handler receives the source type, stream type, and stream identifier you specified. Omitted or null values use EventSourceType.Default, EventStreamType.All, and EventStreamId.Default. Without OnStream, existing givens keep those defaults.

OnStream returns a new builder: you can reuse the original without changing its route. The route does not affect read models seeded with ReadModel(...). The non-generic scenario for fluent registrations also accepts OnStream and passes the route to its callbacks.

A class-based reactor with an [EventSourceType] or [EventStreamType] filter handles only matching givens. A typed [FromEventSource<T>(stream)] filter resolves both values from the registered event source definition:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSources;
using Cratis.Chronicle.Reactors;
using Cratis.Chronicle.Testing;
using Cratis.Chronicle.Testing.Reactors;
[EventSource("TestingReactorScenarioFilteredOrder")]
[EventStream("Payments")]
public record TestingReactorScenarioFilteredOrderSource : IEventSource;
[EventType]
public record TestingReactorScenarioFilteredPaymentReceived(decimal Amount);
[EventType]
public record TestingReactorScenarioFilteredPaymentAccepted(decimal Amount);
[FromEventSource<TestingReactorScenarioFilteredOrderSource>("Payments")]
public class TestingReactorScenarioFilteredPaymentReactor : IReactor
{
public Task<TestingReactorScenarioFilteredPaymentAccepted> Handle(TestingReactorScenarioFilteredPaymentReceived @event) =>
Task.FromResult(new TestingReactorScenarioFilteredPaymentAccepted(@event.Amount));
}
public static class TestingReactorScenarioObserverFilters
{
public static async Task Run()
{
var artifacts = new InMemoryClientArtifactsProvider
{
EventTypes = [typeof(TestingReactorScenarioFilteredPaymentReceived), typeof(TestingReactorScenarioFilteredPaymentAccepted)],
EventSources = [typeof(TestingReactorScenarioFilteredOrderSource)]
};
using var scenario = new ReactorScenario<TestingReactorScenarioFilteredPaymentReactor>(new Defaults(artifacts));
var orderId = EventSourceId.New();
await scenario.Given.ForEventSource(orderId)
.OnStream(eventSourceType: "Customer", eventStreamType: "Payments")
.Events(new TestingReactorScenarioFilteredPaymentReceived(100));
await scenario.Given.ForEventSource(orderId)
.OnStream(eventSourceType: "TestingReactorScenarioFilteredOrder", eventStreamType: "Shipping")
.Events(new TestingReactorScenarioFilteredPaymentReceived(100));
scenario.ShouldNotHaveProduced<TestingReactorScenarioFilteredPaymentAccepted>();
await scenario.Given.ForEventSource(orderId)
.OnStream(eventSourceType: "TestingReactorScenarioFilteredOrder", eventStreamType: "Payments")
.Events(new TestingReactorScenarioFilteredPaymentReceived(100));
scenario.ShouldHaveProduced<TestingReactorScenarioFilteredPaymentAccepted>(accepted => accepted.Amount == 100);
}
}

View C# snippet source on GitHub

The first two givens are ignored; only the matching payment produces a side effect. A reactor without these filters receives events on any route. EventSourceType.Unspecified and EventStreamType.All do not narrow delivery; an explicit EventSourceType.Default filter matches only that literal source type. Stream identifiers do not narrow these filters.

Every given event advances the sequence number, even if the filter skips it, matching the kernel’s append-time numbering. Tag filters (FilterEventsByTag) are not applied: scenario givens do not carry tags. Use a kernel-backed integration specification to verify tag-based delivery.

Use History(...) instead of Events(...) to record an event without invoking the reactor. For example, assign the result of await scenario.Given.ForEventSource(id).History(@event) to an occurrence, then call await scenario.Redeliver(occurrence) to deliver it. Call Redeliver again with the same occurrence to specify recovery or idempotency behavior.

History(object) returns one RecordedOccurrence, containing the original event and its EventContext. History(params object[]) returns an ordered IReadOnlyList<RecordedOccurrence>: multiple arguments, an event array, or no arguments select this overload. A single event selects the single-occurrence overload without ambiguity. Both overloads are also available on the non-generic scenario for fluent registrations.

History allocates scenario-local sequence numbers, correlations, and occurrence times just like live givens. It does not append to an event store, run observers, build read models, or record produced side effects. OnStream(...) before History(...) records the source and stream route in the context. History and live givens share contiguous numbering; redelivery does not advance it.

Redelivery reuses the exact recorded context, including sequence number, correlation, occurrence time, and routing. Two deliveries of that occurrence to the same reactor therefore receive equal ReactorDelivery values and the same delivery Id. Observer filters and read-model seeding or deliberate-absence rules apply as for Events(...); a filtered-out occurrence invokes no handler. Handler exceptions propagate to the caller. Produced events, their destinations, and returned commands accumulate across deliveries in the existing recording lists. Fluent scenarios await the applicable callbacks with the original context; assert on their test doubles as usual.

This seam tests how a reactor handles repeated delivery, not kernel retry scheduling or exactly-once external effects.

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.");
}
}

View C# snippet source on GitHub

Services resolves both the reactor’s constructor dependencies and any service-typed handler-method parameters. If a constructor dependency is missing, the scenario throws CannotActivateReactorForScenario, naming the type it could not resolve. A read-model handler parameter that was neither seeded nor explicitly declared absent throws ReadModelNotSeededForReactorScenario; see Seeding read models.

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");
}
}

View C# snippet source on GitHub

When neither is used, the scenario builds a provider from Services (with logging registered).

Pass a Defaults built with an IClientArtifactsProvider to use event types and other client artifacts from one test run instead of the process-wide registry. The scenario’s in-process event store uses the same provider; a second scenario can supply a different registry without inheriting the first one’s artifacts. The optional service provider, side-effect handlers, and event store can still be supplied with this overload.

using Cratis.Chronicle;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reactors;
using Cratis.Chronicle.Testing;
using Cratis.Chronicle.Testing.Reactors;
using NSubstitute;
[EventType]
public record TestingPerRunReactorEvent(string Value);
public class TestingPerRunReactor : IReactor
{
public Task OnEvent(TestingPerRunReactorEvent @event) => Task.CompletedTask;
}
public static class TestingPerRunReactorExample
{
public static async Task Run()
{
var artifacts = Substitute.For<IClientArtifactsProvider>();
artifacts.EventTypes.Returns([typeof(TestingPerRunReactorEvent)]);
var scenario = new ReactorScenario<TestingPerRunReactor>(new Defaults(artifacts));
await scenario.Given.ForEventSource(EventSourceId.New()).Events(new TestingPerRunReactorEvent("example"));
}
}

View C# snippet source on GitHub

For a reactor configured with ChronicleOptions.ExplicitArtifacts.RegisterReactor(id, define), use ReactorScenario(id, defaults) without a type argument. Construct the defaults with your IClientArtifactsProvider, optional service provider, and options as its three arguments. InMemoryClientArtifactsProvider lets you supply just the event types and other artifacts needed by that run, without assembly discovery.

The scenario builds the named fluent definition once against the run’s event types. Drive it with Given.ForEventSource(id).Events(...); each call awaits every applicable callback and keeps event sequence numbers contiguous across calls. Assert on the effects captured by your callbacks or test doubles. An unknown reactor identifier throws UnknownReactorId, and a callback failure propagates to the caller. Fluent callbacks receive only the event and an optional EventContext; they do not take injected read-model parameters.

This scenario tests fluent handler behavior, not observer delivery, replay, quarantine, or transport registration. Use a kernel-backed integration test for those boundaries. The existing generic ReactorScenario<TReactor> remains the choice for class-based reactors and its produced-side-effect assertions.

A class-based reactor can take a read model as a handler-method parameter, which Chronicle resolves for the event source when the reactor runs. ReactorScenario<TReactor> does not run the projection engine: Given.Events(...) does not build read models, including models marked [Passive]. Seed the expected state explicitly, 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");
}
}

View C# snippet source on GitHub

If a read-model parameter resolves to null without an explicit declaration of absence, the scenario throws ReadModelNotSeededForReactorScenario before invoking the handler. This applies to nullable and non-nullable parameters alike. The message names the type and resolved key, shows the seeding call, and lists any keys already seeded for that type so you can spot a wrong-key seed.

To specify how your reactor behaves when a read model does not exist, declare that absence with NoReadModel<TReadModel>():

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
using Cratis.Chronicle.Reactors;
using Cratis.Chronicle.Testing.Reactors;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
[EventType]
public record TestingReactorNoReadModelVibeStarted(string Host);
[EventType]
public record TestingReactorNoReadModelVibeCancelled();
[FromEvent<TestingReactorNoReadModelVibeStarted>]
public record TestingReactorNoReadModelAttendees([Key] string Id, string Host);
public interface ITestingReactorNoReadModelNotifier
{
Task Notify(string host);
}
public class TestingReactorNoReadModelCancellationReactor(ITestingReactorNoReadModelNotifier notifier) : IReactor
{
public Task Handle(
TestingReactorNoReadModelVibeCancelled @event,
TestingReactorNoReadModelAttendees? attendees) =>
attendees is null ? Task.CompletedTask : notifier.Notify(attendees.Host);
}
public static class TestingReactorNoReadModelExample
{
public static async Task Run()
{
var notifier = Substitute.For<ITestingReactorNoReadModelNotifier>();
var vibeId = EventSourceId.New();
using var scenario = new ReactorScenario<TestingReactorNoReadModelCancellationReactor>();
scenario.Services.AddSingleton(notifier);
// Declare intentional absence so the handler receives null instead of a seeding error.
await scenario.Given.ForEventSourceId(vibeId)
.NoReadModel<TestingReactorNoReadModelAttendees>()
.Events(new TestingReactorNoReadModelVibeCancelled());
await notifier.DidNotReceive().Notify(Arg.Any<string>());
}
}

View C# snippet source on GitHub

The declaration permits null for that type and key; it does not remove a seeded instance. It survives OnStream(...) and applies across the scenario’s builders. When your reactor implements ICanResolveReadModelKey, seed or declare absence under the key it returns, rather than the triggering event source identifier.

The guard applies only to the scenario’s in-process EventStoreForTesting. An explicit IEventStore of another type retains production argument-resolution behavior, including null for an absent read model.

When a reactor returns a built-in event side effect or an asynchronous 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");
}
}

View C# snippet source on GitHub

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.

For a custom synchronous return type, register an IReactorSideEffectHandler in scenario.Services whose CanHandleReturnType(Type) claims that type, as you would in the host. The scenario records the result rather than executing the handler. Unclaimed synchronous types are rejected, just as they are in production; built-in event return types need no extra registration.

An event’s contents can be right while its destination is wrong. Use ProducedEvents to inspect each returned event together with its event source identifier, source type, stream type, and stream identifier:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Reactors;
using Cratis.Chronicle.Testing.Reactors;
[EventType]
public record TestingReactorScenarioDestinationPaymentReceived(decimal Amount);
[EventType]
public record TestingReactorScenarioDestinationPaymentRecorded(decimal Amount);
public class TestingReactorScenarioDestinationReactor : IReactor
{
public EventForEventSourceId Handle(TestingReactorScenarioDestinationPaymentReceived @event) =>
new("ledger", new TestingReactorScenarioDestinationPaymentRecorded(@event.Amount))
{
EventSourceType = "Ledger",
EventStreamType = "Payments",
EventStreamId = "monthly"
};
}
public static class TestingReactorScenarioProducedDestinations
{
public static async Task Run()
{
using var scenario = new ReactorScenario<TestingReactorScenarioDestinationReactor>();
await scenario.Given.ForEventSource(EventSourceId.New())
.Events(new TestingReactorScenarioDestinationPaymentReceived(100));
scenario.ShouldHaveProduced<TestingReactorScenarioDestinationPaymentRecorded>(new EventSourceId("ledger"));
scenario.ShouldHaveProduced<TestingReactorScenarioDestinationPaymentRecorded>((produced, @event) =>
produced.EventSourceId == new EventSourceId("ledger") &&
produced.EventSourceType == new EventSourceType("Ledger") &&
produced.EventStreamType == new EventStreamType("Payments") &&
produced.EventStreamId == new EventStreamId("monthly") && @event.Amount == 100);
foreach (var produced in scenario.ProducedEvents)
{
Console.WriteLine($"{produced.Event.GetType().Name}: {produced.EventSourceId.Value}/{produced.EventStreamType.Value}/{produced.EventStreamId.Value}");
}
}
}

View C# snippet source on GitHub

ShouldHaveProduced<TEvent>(eventSourceId) checks the destination source. The two-argument predicate overload receives a ProducedEvent and the typed event, so you can check both destination and contents. A failing destination assertion lists the recorded destinations. Produced and the existing content-only assertions remain unchanged; commands and other non-event side effects appear only in Produced.

Bare returned events use the reactor’s context values: the source identifier comes from ICanProvideEventSourceId when implemented, otherwise from the triggering event. Reactor-level source and stream attributes and ICanProvideEventStreamId supply the remaining route values. Unset values use event-log defaults, not the triggering event’s source or stream types or stream identifier. An EventForEventSourceId uses its own destination metadata instead, including an event source definition when specified. Collections preserve each event’s destination.

The scenario records these events without appending them. If you supply explicit side-effect handlers, assert against those handlers instead; neither recording list is populated.

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");
}

View C# snippet source on GitHub

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>());
}
}

View C# snippet source on GitHub

  • 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.