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.
Start with a pure function
Section titled “Start with a pure function”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");}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reactorimport org.junit.jupiter.api.Assertions.assertEqualsimport org.junit.jupiter.api.Test
@EventTypedata class VibeCancelled(val host: String)
@EventTypedata class NotificationRequested(val host: String)
/** Returns an event that Chronicle appends; its logic is a pure function of the input event. */@Reactorclass CancellationReactor { fun vibeCancelled(event: VibeCancelled) = NotificationRequested(event.host)}
class CancellationReactorTests {
@Test fun `a cancelled vibe requests a notification for its host`() { val reactor = CancellationReactor()
val notification = reactor.vibeCancelled(VibeCancelled("Ada"))
assertEquals("Ada", notification.host) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reactor;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class TestingReactorsPureFunction {
@EventType record VibeCancelled(String host) { }
@EventType record NotificationRequested(String host) { }
/** Returns an event that Chronicle appends; its logic is a pure function of the input event. */ @Reactor static class CancellationReactor { NotificationRequested vibeCancelled(VibeCancelled event) { return new NotificationRequested(event.host()); } }
@Test void aCancelledVibeRequestsANotificationForItsHost() { var reactor = new CancellationReactor();
var notification = reactor.vibeCancelled(new VibeCancelled("Ada"));
assertEquals("Ada", notification.host()); }}defmodule MyApp.Events.TestingPureVibeCancelled do use Chronicle.Events.EventType, id: "testing-pure-function-vibe-cancelled"
defstruct [:host]end
defmodule MyApp.Events.TestingPureCreateNotification do use Chronicle.Events.EventType, id: "testing-pure-function-create-notification"
defstruct [:host]end
# The reactor returns the notification event as a side effect, so its logic is a# pure function of (event, context) — no call to Chronicle.EventSequences.EventLog# and no client needed to exercise it.defmodule MyApp.Reactors.TestingPureCancellationReactor do use Chronicle.Reactors.Reactor
alias MyApp.Events.{TestingPureCreateNotification, TestingPureVibeCancelled}
@handles TestingPureVibeCancelled
@impl true def handle(%TestingPureVibeCancelled{host: host}, _context), do: {:ok, %TestingPureCreateNotification{host: host}}end
defmodule MyApp.TestingPureFunctionTest do use ExUnit.Case, async: true
alias Chronicle.Events.EventContext alias MyApp.Events.{TestingPureCreateNotification, TestingPureVibeCancelled} alias MyApp.Reactors.TestingPureCancellationReactor
test "a cancelled vibe requests a notification for the host" do event = %TestingPureVibeCancelled{host: "Ada"} context = %EventContext{event_source_id: "vibe-1"}
assert {:ok, %TestingPureCreateNotification{host: "Ada"}} = TestingPureCancellationReactor.handle(event, context) endendTypeScript does not support this workflow yet.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.
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"); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.TestingReactorScenarioSomeEvent do use Chronicle.Events.EventType, id: "testing-reactor-scenario-some-event"
defstruct [:value]end
defmodule MyApp.Events.TestingReactorScenarioSomeOtherEvent do use Chronicle.Events.EventType, id: "testing-reactor-scenario-some-other-event"
defstruct [:value]end
defmodule MyApp.TestingReactorScenarioService do @callback do_something(String.t()) :: :okend
# The reactor resolves its collaborator from the application environment, so a spec# can swap in a recording stand-in.defmodule MyApp.Reactors.TestingReactorScenarioMyReactor do use Chronicle.Reactors.Reactor
alias MyApp.Events.TestingReactorScenarioSomeEvent
@handles TestingReactorScenarioSomeEvent
@impl true def handle(%TestingReactorScenarioSomeEvent{value: value}, _context) do service = Application.fetch_env!(:my_app, :testing_reactor_scenario_service) service.do_something(value) endend
defmodule MyApp.TestingReactorScenarioRecordingService do @behaviour MyApp.TestingReactorScenarioService
def start_link, do: Agent.start_link(fn -> [] end, name: __MODULE__) def calls, do: Agent.get(__MODULE__, &Enum.reverse/1)
@impl true def do_something(value), do: Agent.update(__MODULE__, &[value | &1])end
defmodule MyApp.TestingReactorScenarioBasicTest do use Chronicle.Testing.Case, event_types: [ MyApp.Events.TestingReactorScenarioSomeEvent, MyApp.Events.TestingReactorScenarioSomeOtherEvent ], reactors: [MyApp.Reactors.TestingReactorScenarioMyReactor]
alias MyApp.Events.{TestingReactorScenarioSomeEvent, TestingReactorScenarioSomeOtherEvent} alias MyApp.TestingReactorScenarioRecordingService, as: Service
setup do {:ok, _service} = Service.start_link() Application.put_env(:my_app, :testing_reactor_scenario_service, Service) on_exit(fn -> Application.delete_env(:my_app, :testing_reactor_scenario_service) end) end
test "the reactor calls the service for the event it handles", %{chronicle: client} do some_id = "some-#{System.unique_integer([:positive])}"
:ok = Chronicle.EventSequences.EventLog.append_many( some_id, [ %TestingReactorScenarioSomeEvent{value: "value"}, %TestingReactorScenarioSomeOtherEvent{value: 42} ], client: client )
:ok = Chronicle.Testing.wait_for_observers(client)
# Assert on the side effects the stand-in recorded assert Service.calls() == ["value"] endendimport { field } from '@cratis/fundamentals';import { eventType } from '@cratis/chronicle';import { reactor } from '@cratis/chronicle';import { ReactorScenario } from '@cratis/chronicle/testing';
class BasicWelcomeRequested { @field(String) name: string; constructor(name: string) { this.name = name; }}eventType('BasicWelcomeRequested')(BasicWelcomeRequested);
class BasicWelcomeSent { @field(String) name: string; constructor(name: string) { this.name = name; }}eventType('BasicWelcomeSent')(BasicWelcomeSent);
class BasicWelcomeReactor { basicWelcomeRequested(event: BasicWelcomeRequested) { return new BasicWelcomeSent(event.name); }}reactor('BasicWelcomeReactor')(BasicWelcomeReactor);
const basicReactorScenario = new ReactorScenario(BasicWelcomeReactor, { artifacts: { eventTypes: [BasicWelcomeRequested, BasicWelcomeSent] }, constraints: 'disabled'});await basicReactorScenario.given.forEventSource('customer-1').events(new BasicWelcomeRequested('alice'));await basicReactorScenario.when.forEventSource('customer-2').events(new BasicWelcomeRequested('bob'));// Both deliveries complete immediately; returned events are recorded, not appended.Given is a fluent builder: call ForEventSource(id) to specify the event source, then Events(...) to route events through the reactor in order.
Routed givens
Section titled “Routed givens”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"); }}defmodule MyApp.Events.TestingReactorScenarioRoutedPaymentReceived do use Chronicle.Events.EventType, id: "testing-reactor-scenario-routed-payment-received"
defstruct [:amount]end
defmodule MyApp.Events.TestingReactorScenarioPaymentRouteObserved do use Chronicle.Events.EventType, id: "testing-reactor-scenario-payment-route-observed"
defstruct [:amount, :source_type, :stream_type, :stream_id]end
defmodule MyApp.Reactors.TestingReactorScenarioRouteReactor do use Chronicle.Reactors.Reactor
alias MyApp.Events.{ TestingReactorScenarioPaymentRouteObserved, TestingReactorScenarioRoutedPaymentReceived }
@handles TestingReactorScenarioRoutedPaymentReceived
@impl true def handle(%TestingReactorScenarioRoutedPaymentReceived{amount: amount}, context) do {:ok, %TestingReactorScenarioPaymentRouteObserved{ amount: amount, source_type: context.event_source_type, stream_type: context.event_stream_type, stream_id: context.event_stream_id }} endend
defmodule MyApp.TestingReactorScenarioRoutedEventsTest do use Chronicle.Testing.Case, event_types: [ MyApp.Events.TestingReactorScenarioRoutedPaymentReceived, MyApp.Events.TestingReactorScenarioPaymentRouteObserved ], reactors: [MyApp.Reactors.TestingReactorScenarioRouteReactor]
import Chronicle.Testing.Assertions
alias MyApp.Events.{ TestingReactorScenarioPaymentRouteObserved, TestingReactorScenarioRoutedPaymentReceived }
test "the reactor sees where the event was routed", %{chronicle: client} do :ok = Chronicle.EventSequences.EventLog.append( "order-1", %TestingReactorScenarioRoutedPaymentReceived{amount: 100}, client: client, event_source_type: "Order", event_stream_type: "Payments", event_stream_id: "monthly" )
:ok = Chronicle.Testing.wait_for_observers(client)
assert_appended(client, %TestingReactorScenarioPaymentRouteObserved{ amount: 100, source_type: "Order", stream_type: "Payments", stream_id: "monthly" }) endendThe 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.
Observer filters
Section titled “Observer filters”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); }}Elixir does not support this workflow yet.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.
Redeliver a recorded occurrence
Section titled “Redeliver a recorded occurrence”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.
Injecting dependencies
Section titled “Injecting dependencies”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."); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.TestingReactorScenarioServicesBookingCancelled do use Chronicle.Events.EventType, id: "testing-reactor-scenario-services-booking-cancelled"
defstruct [:booking_id]end
defmodule MyApp.TestingReactorScenarioServicesNotifications do @callback notify(String.t()) :: :okend
defmodule MyApp.Reactors.TestingReactorScenarioServicesCancellationReactor do use Chronicle.Reactors.Reactor
alias MyApp.Events.TestingReactorScenarioServicesBookingCancelled
@handles TestingReactorScenarioServicesBookingCancelled
@impl true def handle(%TestingReactorScenarioServicesBookingCancelled{booking_id: booking_id}, _context) do notifications = Application.fetch_env!(:my_app, :testing_reactor_scenario_notifications) notifications.notify("Booking #{booking_id} was cancelled.") endend
defmodule MyApp.TestingReactorScenarioServicesRecordingNotifications do @behaviour MyApp.TestingReactorScenarioServicesNotifications
def start_link, do: Agent.start_link(fn -> [] end, name: __MODULE__) def messages, do: Agent.get(__MODULE__, &Enum.reverse/1)
@impl true def notify(message), do: Agent.update(__MODULE__, &[message | &1])end
defmodule MyApp.TestingReactorScenarioServicesTest do use Chronicle.Testing.Case, event_types: [MyApp.Events.TestingReactorScenarioServicesBookingCancelled], reactors: [MyApp.Reactors.TestingReactorScenarioServicesCancellationReactor]
alias MyApp.Events.TestingReactorScenarioServicesBookingCancelled alias MyApp.TestingReactorScenarioServicesRecordingNotifications, as: Notifications
setup do # Register the stand-in the reactor will resolve {:ok, _notifications} = Notifications.start_link() Application.put_env(:my_app, :testing_reactor_scenario_notifications, Notifications) on_exit(fn -> Application.delete_env(:my_app, :testing_reactor_scenario_notifications) end) end
test "a cancelled booking sends a notification", %{chronicle: client} do :ok = Chronicle.EventSequences.EventLog.append( "booking-123", %TestingReactorScenarioServicesBookingCancelled{booking_id: "booking-123"}, client: client )
:ok = Chronicle.Testing.wait_for_observers(client)
assert Notifications.messages() == ["Booking booking-123 was cancelled."] endendimport { field } from '@cratis/fundamentals';import { eventType, reactor } from '@cratis/chronicle';import { ReactorScenario } from '@cratis/chronicle/testing';
class BookingCancelled { @field(String) bookingId: string; constructor(bookingId: string) { this.bookingId = bookingId; }}eventType('BookingCancelled')(BookingCancelled);const notified: string[] = [];class CancellationReactor { constructor(private readonly notifications: { notify(message: string): Promise<void> }) {} bookingCancelled(event: BookingCancelled): Promise<void> { return this.notifications.notify(`Booking ${event.bookingId} was cancelled.`); }}reactor('CancellationReactor')(CancellationReactor);// The artifact activator supplies the reactor's dependencies, as the production client's hook does.const cancellationScenario = new ReactorScenario(CancellationReactor, { artifacts: { eventTypes: [BookingCancelled] }, constraints: 'disabled', artifactActivator: type => ({ instance: new type({ notify: async (message: string) => { notified.push(message); } }) })});await cancellationScenario.given.forEventSource('booking-123').events(new BookingCancelled('booking-123'));// notified contains 'Booking booking-123 was cancelled.'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"); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.TestingReactorScenarioOrderShipped do use Chronicle.Events.EventType, id: "testing-reactor-scenario-order-shipped"
defstruct [:order_id, :carrier]end
defmodule MyApp.TestingReactorScenarioOrderRepository do @callback mark_as_shipped(String.t()) :: :okend
defmodule MyApp.Reactors.TestingReactorScenarioOrderReactor do use Chronicle.Reactors.Reactor
alias MyApp.Events.TestingReactorScenarioOrderShipped
@handles TestingReactorScenarioOrderShipped
# The dependency is looked up when the event is handled, not compiled in. @impl true def handle(%TestingReactorScenarioOrderShipped{order_id: order_id}, _context) do repository = Application.fetch_env!(:my_app, :testing_reactor_scenario_order_repository) repository.mark_as_shipped(order_id) endend
defmodule MyApp.TestingReactorScenarioRecordingOrderRepository do @behaviour MyApp.TestingReactorScenarioOrderRepository
def start_link, do: Agent.start_link(fn -> [] end, name: __MODULE__) def shipped, do: Agent.get(__MODULE__, &Enum.reverse/1)
@impl true def mark_as_shipped(order_id), do: Agent.update(__MODULE__, &[order_id | &1])end
defmodule MyApp.TestingReactorScenarioInjectingDependenciesTest do use Chronicle.Testing.Case, event_types: [MyApp.Events.TestingReactorScenarioOrderShipped], reactors: [MyApp.Reactors.TestingReactorScenarioOrderReactor]
alias MyApp.Events.TestingReactorScenarioOrderShipped alias MyApp.TestingReactorScenarioRecordingOrderRepository, as: OrderRepository
setup do {:ok, _repository} = OrderRepository.start_link() Application.put_env(:my_app, :testing_reactor_scenario_order_repository, OrderRepository) on_exit(fn -> Application.delete_env(:my_app, :testing_reactor_scenario_order_repository) end) end
test "the reactor marks the order as shipped through its dependency", %{chronicle: client} do :ok = Chronicle.EventSequences.EventLog.append( "order-123", %TestingReactorScenarioOrderShipped{order_id: "order-123", carrier: "FedEx"}, client: client )
:ok = Chronicle.Testing.wait_for_observers(client)
assert OrderRepository.shipped() == ["order-123"] endendimport { field } from '@cratis/fundamentals';import { eventType, reactor } from '@cratis/chronicle';import { ReactorScenario } from '@cratis/chronicle/testing';
class InjectedWelcomeRequested { @field(String) name: string; constructor(name: string) { this.name = name; }}eventType('InjectedWelcomeRequested')(InjectedWelcomeRequested);const sent: string[] = [];class InjectedWelcomeReactor { constructor(private readonly notifier: { send(name: string): void }) {} injectedWelcomeRequested(event: InjectedWelcomeRequested) { this.notifier.send(event.name); }}reactor('InjectedWelcomeReactor')(InjectedWelcomeReactor);const injectedScenario = new ReactorScenario(InjectedWelcomeReactor, { artifacts: { eventTypes: [InjectedWelcomeRequested] }, constraints: 'disabled', artifactActivator: type => ({ instance: new type({ send: (name: string) => sent.push(name) }) })});await injectedScenario.when.forEventSource('customer-1').events(new InjectedWelcomeRequested('alice'));// sent contains 'alice'; activation uses the same hook as the production client.When neither is used, the scenario builds a provider from Services (with logging registered).
Per-run artifacts
Section titled “Per-run artifacts”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")); }}defmodule MyApp.Events.TestingPerRunReactorEvent do use Chronicle.Events.EventType, id: "testing-per-run-reactor-event"
defstruct [:value]end
defmodule MyApp.Reactors.TestingPerRunReactor do use Chronicle.Reactors.Reactor
alias MyApp.Events.TestingPerRunReactorEvent
@handles TestingPerRunReactorEvent
@impl true def handle(%TestingPerRunReactorEvent{}, _context), do: :okend
defmodule MyApp.TestingPerRunReactorExampleTest do use ExUnit.Case, async: true
alias Chronicle.EventSequences.EventLog alias MyApp.Events.TestingPerRunReactorEvent
test "a run registers only the artifacts it names" do # The artifacts belong to this run's client, not to the whole application. {:ok, client} = Chronicle.Testing.start_supervised_client( event_types: [TestingPerRunReactorEvent], reactors: [MyApp.Reactors.TestingPerRunReactor] )
on_exit(fn -> Chronicle.Testing.stop_supervised_client(client) end)
:ok = EventLog.append("event-source-1", %TestingPerRunReactorEvent{value: "example"}, client: client) :ok = Chronicle.Testing.wait_for_observers(client) endendFluent registrations (.NET)
Section titled “Fluent registrations (.NET)”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.
Seeding read models
Section titled “Seeding read models”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"); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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>()); }}Elixir does not support this workflow yet.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.
Asserting produced side effects
Section titled “Asserting produced side effects”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"); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.TestingReactorScenarioReminderVibeStarted do use Chronicle.Events.EventType, id: "testing-reactor-scenario-reminder-vibe-started"
defstruct [:host]end
defmodule MyApp.Events.TestingReactorScenarioSendReminder do use Chronicle.Events.EventType, id: "testing-reactor-scenario-send-reminder"
defstruct [:host]end
defmodule MyApp.Reactors.TestingReactorScenarioReminderReactor do use Chronicle.Reactors.Reactor
alias MyApp.Events.{TestingReactorScenarioReminderVibeStarted, TestingReactorScenarioSendReminder}
@handles TestingReactorScenarioReminderVibeStarted
@impl true def handle(%TestingReactorScenarioReminderVibeStarted{host: host}, _context), do: {:ok, %TestingReactorScenarioSendReminder{host: host}}end
defmodule MyApp.TestingReactorScenarioProducedSideEffectsTest do use Chronicle.Testing.Case, event_types: [ MyApp.Events.TestingReactorScenarioReminderVibeStarted, MyApp.Events.TestingReactorScenarioSendReminder ], reactors: [MyApp.Reactors.TestingReactorScenarioReminderReactor]
import Chronicle.Testing.Assertions
alias MyApp.Events.{TestingReactorScenarioReminderVibeStarted, TestingReactorScenarioSendReminder}
test "a started vibe produces a reminder for the host", %{chronicle: client} do vibe_id = "vibe-#{System.unique_integer([:positive])}"
:ok = Chronicle.EventSequences.EventLog.append( vibe_id, %TestingReactorScenarioReminderVibeStarted{host: "Ada"}, client: client )
:ok = Chronicle.Testing.wait_for_observers(client)
assert_appended(client, %TestingReactorScenarioSendReminder{host: "Ada"}, event_source_id: vibe_id) endendimport { field } from '@cratis/fundamentals';import { eventType, reactor } from '@cratis/chronicle';import { ReactorScenario } from '@cratis/chronicle/testing';
class ProducedRegistration { @field(String) name: string; constructor(name: string) { this.name = name; }}eventType('ProducedRegistration')(ProducedRegistration);class ProducedNotification { @field(String) name: string; constructor(name: string) { this.name = name; }}eventType('ProducedNotification')(ProducedNotification);class ProducedReactor { producedRegistration(event: ProducedRegistration) { return { eventSourceId: 'notifications', event: new ProducedNotification(event.name) }; }}reactor('ProducedReactor')(ProducedReactor);const producedScenario = new ReactorScenario(ProducedReactor, { artifacts: { eventTypes: [ProducedRegistration, ProducedNotification] }, constraints: 'disabled'});await producedScenario.when.forEventSource('customer-1').events(new ProducedRegistration('alice'));producedScenario.shouldHaveProduced(ProducedNotification, event => event.name === 'alice');// producedScenario.sideEffects[0].target.eventSourceId === 'notifications'; no event is auto-appended.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.
Asserting produced destinations
Section titled “Asserting produced destinations”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}"); } }}defmodule MyApp.Events.TestingReactorScenarioDestinationPaymentReceived do use Chronicle.Events.EventType, id: "testing-reactor-scenario-destination-payment-received"
defstruct [:amount]end
defmodule MyApp.Events.TestingReactorScenarioDestinationPaymentRecorded do use Chronicle.Events.EventType, id: "testing-reactor-scenario-destination-payment-recorded"
defstruct [:amount]end
defmodule MyApp.Reactors.TestingReactorScenarioDestinationReactor do use Chronicle.Reactors.Reactor
alias Chronicle.EventSequences.EventForEventSourceId
alias MyApp.Events.{ TestingReactorScenarioDestinationPaymentReceived, TestingReactorScenarioDestinationPaymentRecorded }
@handles TestingReactorScenarioDestinationPaymentReceived
@impl true def handle(%TestingReactorScenarioDestinationPaymentReceived{amount: amount}, _context) do {:ok, %EventForEventSourceId{ event_source_id: "ledger", event: %TestingReactorScenarioDestinationPaymentRecorded{amount: amount}, event_source_type: "Ledger", event_stream_type: "Payments", event_stream_id: "monthly" }} endend
defmodule MyApp.TestingReactorScenarioProducedDestinationsTest do use Chronicle.Testing.Case, event_types: [ MyApp.Events.TestingReactorScenarioDestinationPaymentReceived, MyApp.Events.TestingReactorScenarioDestinationPaymentRecorded ], reactors: [MyApp.Reactors.TestingReactorScenarioDestinationReactor]
import Chronicle.Testing.Assertions
alias MyApp.Events.{ TestingReactorScenarioDestinationPaymentReceived, TestingReactorScenarioDestinationPaymentRecorded }
test "the reactor records the payment on the ledger", %{chronicle: client} do :ok = Chronicle.EventSequences.EventLog.append( "order-1", %TestingReactorScenarioDestinationPaymentReceived{amount: 100}, client: client )
:ok = Chronicle.Testing.wait_for_observers(client)
assert_appended(client, %TestingReactorScenarioDestinationPaymentRecorded{amount: 100}, event_source_id: "ledger" )
for produced <- Chronicle.Testing.appended_events(client, "ledger") do context = produced.context IO.puts("#{inspect(produced.event.__struct__)}: #{context.event_source_type}/#{context.event_stream_type}/#{context.event_stream_id}") end
[recorded] = Chronicle.Testing.appended_events(client, "ledger") assert recorded.context.event_source_type == "Ledger" assert recorded.context.event_stream_type == "Payments" assert recorded.context.event_stream_id == "monthly" endendShouldHaveProduced<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.
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");}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.TestingReactorScenarioEmailOrderShipped do use Chronicle.Events.EventType, id: "email-testing-order-shipped"
defstruct [:order_id, :carrier]end
defmodule MyApp.TestingReactorScenarioEmailService do @callback send_shipping_confirmation(String.t(), String.t()) :: :okend
defmodule MyApp.Reactors.TestingReactorScenarioOrderNotificationReactor do use Chronicle.Reactors.Reactor
alias MyApp.Events.TestingReactorScenarioEmailOrderShipped
@handles TestingReactorScenarioEmailOrderShipped
@impl true def handle(%TestingReactorScenarioEmailOrderShipped{} = event, _context) do email_service = Application.fetch_env!(:my_app, :testing_reactor_scenario_email_service) email_service.send_shipping_confirmation(event.order_id, event.carrier) endend
defmodule MyApp.TestingReactorScenarioRecordingEmailService do @behaviour MyApp.TestingReactorScenarioEmailService
def start_link, do: Agent.start_link(fn -> [] end, name: __MODULE__) def sent, do: Agent.get(__MODULE__, &Enum.reverse/1)
@impl true def send_shipping_confirmation(order_id, carrier), do: Agent.update(__MODULE__, &[{order_id, carrier} | &1])end
defmodule MyApp.TestingReactorScenarioEmailExampleTest do use Chronicle.Testing.Case, event_types: [MyApp.Events.TestingReactorScenarioEmailOrderShipped], reactors: [MyApp.Reactors.TestingReactorScenarioOrderNotificationReactor]
alias MyApp.Events.TestingReactorScenarioEmailOrderShipped alias MyApp.TestingReactorScenarioRecordingEmailService, as: EmailService
setup do {:ok, _email_service} = EmailService.start_link() Application.put_env(:my_app, :testing_reactor_scenario_email_service, EmailService) on_exit(fn -> Application.delete_env(:my_app, :testing_reactor_scenario_email_service) end) end
test "when an order is shipped a shipping confirmation is sent", %{chronicle: client} do :ok = Chronicle.EventSequences.EventLog.append( "order-123", %TestingReactorScenarioEmailOrderShipped{order_id: "order-123", carrier: "DHL"}, client: client )
:ok = Chronicle.Testing.wait_for_observers(client)
assert EmailService.sent() == [{"order-123", "DHL"}] endendimport { field } from '@cratis/fundamentals';import { eventType, reactor } from '@cratis/chronicle';import { ReactorScenario } from '@cratis/chronicle/testing';
class OrderShipped { @field(String) orderId: string; @field(String) carrier: string; constructor(orderId: string, carrier: string) { this.orderId = orderId; this.carrier = carrier; }}eventType('email-testing-order-shipped')(OrderShipped);interface EmailService { sendShippingConfirmation(orderId: string, carrier: string): Promise<void>; }class OrderNotificationReactor { constructor(private readonly emailService: EmailService) {} async orderShipped(event: OrderShipped): Promise<void> { await this.emailService.sendShippingConfirmation(event.orderId, event.carrier); }}reactor('OrderNotificationReactor')(OrderNotificationReactor);const confirmations: string[] = [];const emailScenario = new ReactorScenario(OrderNotificationReactor, { artifacts: { eventTypes: [OrderShipped] }, constraints: 'disabled', artifactActivator: type => ({ instance: new type({ sendShippingConfirmation: async (orderId: string, carrier: string) => { confirmations.push(`${orderId}:${carrier}`); } }) })});await emailScenario.given.forEventSource('order-123').events(new OrderShipped('order-123', 'DHL'));// confirmations contains '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>()); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.TestingReactorScenarioTenantActivated do use Chronicle.Events.EventType, id: "testing-reactor-scenario-tenant-activated"
defstruct [:tenant_id]end
defmodule MyApp.TestingReactorScenarioSyncService do @callback sync_tenant(String.t()) :: :okend
defmodule MyApp.Reactors.TestingReactorScenarioTenantSyncReactor do use Chronicle.Reactors.Reactor
alias MyApp.Events.TestingReactorScenarioTenantActivated
@handles TestingReactorScenarioTenantActivated
@impl true def handle(%TestingReactorScenarioTenantActivated{tenant_id: tenant_id}, _context) do Application.fetch_env!(:my_app, :testing_reactor_scenario_sync_service).sync_tenant(tenant_id) endend
defmodule MyApp.TestingReactorScenarioRecordingSyncService do @behaviour MyApp.TestingReactorScenarioSyncService
def start_link, do: Agent.start_link(fn -> [] end, name: __MODULE__) def calls, do: Agent.get(__MODULE__, &Enum.reverse/1)
@impl true def sync_tenant(tenant_id), do: Agent.update(__MODULE__, &[tenant_id | &1])end
defmodule MyApp.TestingReactorScenarioMultipleSourcesTest do use Chronicle.Testing.Case, event_types: [MyApp.Events.TestingReactorScenarioTenantActivated], reactors: [MyApp.Reactors.TestingReactorScenarioTenantSyncReactor]
alias MyApp.Events.TestingReactorScenarioTenantActivated alias MyApp.TestingReactorScenarioRecordingSyncService, as: SyncService
setup do {:ok, _service} = SyncService.start_link() Application.put_env(:my_app, :testing_reactor_scenario_sync_service, SyncService) on_exit(fn -> Application.delete_env(:my_app, :testing_reactor_scenario_sync_service) end) end
test "events from different event sources are all handled", %{chronicle: client} do # Events from two different tenants :ok = Chronicle.EventSequences.EventLog.append( "tenant-A", %TestingReactorScenarioTenantActivated{tenant_id: "tenant-A"}, client: client )
:ok = Chronicle.EventSequences.EventLog.append( "tenant-B", %TestingReactorScenarioTenantActivated{tenant_id: "tenant-B"}, client: client )
:ok = Chronicle.Testing.wait_for_observers(client)
# Both activations should have been handled assert SyncService.calls() == ["tenant-A", "tenant-B"] endendimport { field } from '@cratis/fundamentals';import { eventType, reactor } from '@cratis/chronicle';import { ReactorScenario } from '@cratis/chronicle/testing';
class TenantActivated { @field(String) tenantId: string; constructor(tenantId: string) { this.tenantId = tenantId; }}eventType('TenantActivated')(TenantActivated);const synced: string[] = [];class TenantSyncReactor { constructor(private readonly syncService: { syncTenant(tenantId: string): Promise<void> }) {} tenantActivated(event: TenantActivated): Promise<void> { return this.syncService.syncTenant(event.tenantId); }}reactor('TenantSyncReactor')(TenantSyncReactor);const tenantScenario = new ReactorScenario(TenantSyncReactor, { artifacts: { eventTypes: [TenantActivated] }, constraints: 'disabled', artifactActivator: type => ({ instance: new type({ syncTenant: async (tenantId: string) => { synced.push(tenantId); } }) })});// Events from two different tenants, each delivered to its own source partition.await tenantScenario.given.forEventSource('tenant-A').events(new TenantActivated('tenant-A'));await tenantScenario.given.forEventSource('tenant-B').events(new TenantActivated('tenant-B'));// synced contains 'tenant-A' and 'tenant-B'.- 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 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.