ReadModelScenario
ReadModelScenario<TReadModel> is a lightweight, in-process test utility that lets you verify the output of read model projections and reducers without a running Chronicle server or database.
It auto-detects how TReadModel is backed — by a reducer, a fluent projection, or a model-bound projection — and routes events through the appropriate engine.
In a Cratis Specification, the event history is the given, Because() feeds that history through the scenario, and the read model properties are the then.
Why use ReadModelScenario
Section titled “Why use ReadModelScenario”Integration tests against a live database are accurate but slow and fragile. ReadModelScenario<TReadModel> runs the same projection and reducer logic entirely in-process using null-stub storage, so your test suite stays fast without sacrificing correctness.
Installation
Section titled “Installation”ReadModelScenario<TReadModel> 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.Keys;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;using Xunit;
[EventType]public record TestingReadModelScenarioSomeEvent(string Value);
[EventType]public record TestingReadModelScenarioSomeOtherEvent(int Value);
[FromEvent<TestingReadModelScenarioSomeEvent>]public record TestingReadModelScenarioMyReadModel([Key] Guid Id, string Value);
public class when_projecting_events : Specification{ readonly EventSourceId _eventSourceId = EventSourceId.New(); readonly ReadModelScenario<TestingReadModelScenarioMyReadModel> _scenario = new();
Task Because() => _scenario.Given .ForEventSource(_eventSourceId) .Events(new TestingReadModelScenarioSomeEvent("expected value"), new TestingReadModelScenarioSomeOtherEvent(42));
[Fact] void should_project_the_value() => _scenario.Instance!.Value.ShouldEqual("expected value");}Given is a fluent builder: call ForEventSource(id) to specify the event source, then Events(...) to feed events through the read model’s projection or reducer in order. The result is stored in Instance.
Optional initial state
Section titled “Optional initial state”Pass an initial state to the constructor when the read model starts from a non-default baseline:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reducers;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
[EventType]public record TestingReadModelScenarioItemAdded;
public record TestingReadModelScenarioCountingReadModel(int Count);
public class TestingReadModelScenarioCountingReducer : IReducerFor<TestingReadModelScenarioCountingReadModel>{ public TestingReadModelScenarioCountingReadModel ItemAdded(TestingReadModelScenarioItemAdded @event, TestingReadModelScenarioCountingReadModel? current, EventContext context) => current is null ? new(1) : current with { Count = current.Count + 1 };}
public static class TestingReadModelScenarioInitialState{ public static async Task Run() { var myId = EventSourceId.New(); var initial = new TestingReadModelScenarioCountingReadModel(10); var scenario = new ReadModelScenario<TestingReadModelScenarioCountingReadModel>(initial); await scenario.Given .ForEventSource(myId) .Events(new TestingReadModelScenarioItemAdded());
scenario.Instance!.Count.ShouldEqual(11); }}Injecting dependencies
Section titled “Injecting dependencies”Pass an IServiceProvider to the constructor to supply mocks and stubs that the reducer or projection depends on:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reducers;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;using Microsoft.Extensions.DependencyInjection;using NSubstitute;
[EventType]public record TestingReadModelScenarioOrderCreated(string OrderId);
public record TestingReadModelScenarioOrderSummary(string OrderId, decimal Total);
public interface ITestingReadModelScenarioPricingService{ decimal GetBasePrice();}
public class TestingReadModelScenarioOrderSummaryReducer(ITestingReadModelScenarioPricingService pricingService) : IReducerFor<TestingReadModelScenarioOrderSummary>{ public TestingReadModelScenarioOrderSummary OrderCreated(TestingReadModelScenarioOrderCreated @event, TestingReadModelScenarioOrderSummary? current, EventContext context) => new(@event.OrderId, pricingService.GetBasePrice());}
public static class TestingReadModelScenarioInjectingDependencies{ public static async Task Run() { var pricingService = Substitute.For<ITestingReadModelScenarioPricingService>(); var services = new ServiceCollection() .AddSingleton(pricingService) .BuildServiceProvider();
var scenario = new ReadModelScenario<TestingReadModelScenarioOrderSummary>(initialState: null, serviceProvider: services); await scenario.Given .ForEventSource("order-1") .Events(new TestingReadModelScenarioOrderCreated("order-1"));
scenario.Instance!.Total.ShouldEqual(0m); }}Pre-seeding read model instances
Section titled “Pre-seeding read model instances”Use Given.ForEventSourceId(id).ReadModel(instance) to register a pre-built read model instance for
production code under test that calls IReadModels.GetInstanceById. This lets you test a service that
fetches a read model by ID without replaying events through a full projection:
using Cratis.Chronicle.ReadModels;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
public class TestingReadModelScenarioOrderService(IReadModels readModels){ public async Task<decimal> GetOrderTotal(string orderId) { var order = await readModels.GetInstanceById<TestingReadModelScenarioOrderSummary>(orderId); return order.Total; }}
public static class TestingReadModelScenarioPreseedingInstances{ public static async Task Run() { var scenario = new ReadModelScenario<TestingReadModelScenarioOrderSummary>(); await scenario.Given .ForEventSourceId("order-1") .ReadModel(new TestingReadModelScenarioOrderSummary("order-1", 99.99m));
// Pass scenario.ReadModels to production code under test var sut = new TestingReadModelScenarioOrderService(scenario.ReadModels); var result = await sut.GetOrderTotal("order-1");
result.ShouldEqual(99.99m); }}scenario.ReadModels returns an IReadModels instance that intercepts GetInstanceById for registered
instances and delegates everything else to the real read model implementation.
Auto-detection
Section titled “Auto-detection”ReadModelScenario<TReadModel> searches the current application’s loaded assemblies for a handler in this order:
- Reducer — a class implementing
IReducerFor<TReadModel>. - Fluent projection — a class implementing
IProjectionFor<TReadModel>. - Model-bound projection —
TReadModelitself carries[FromEvent<T>]or[Key]attributes.
If none are found, NoReadModelHandlerFound is thrown.
Example: reducer
Section titled “Example: reducer”using Cratis.Chronicle.Events;using Cratis.Chronicle.Reducers;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
[EventType]public record TestingReadModelScenarioReducerOrderCreated(string OrderId);
[EventType]public record TestingReadModelScenarioReducerItemAdded(decimal Price);
public record TestingReadModelScenarioReducerOrderSummary(string OrderId, decimal Total);
public class TestingReadModelScenarioReducerOrderSummaryReducer : IReducerFor<TestingReadModelScenarioReducerOrderSummary>{ public TestingReadModelScenarioReducerOrderSummary OnOrderCreated(TestingReadModelScenarioReducerOrderCreated @event, TestingReadModelScenarioReducerOrderSummary? current, EventContext context) => new(@event.OrderId, 0m);
public TestingReadModelScenarioReducerOrderSummary OnItemAdded(TestingReadModelScenarioReducerItemAdded @event, TestingReadModelScenarioReducerOrderSummary current, EventContext context) => current with { Total = current.Total + @event.Price };}
public static class TestingReadModelScenarioReducerExample{ public static async Task Run() { var orderId = "order-1"; var scenario = new ReadModelScenario<TestingReadModelScenarioReducerOrderSummary>(); await scenario.Given .ForEventSource(orderId) .Events( new TestingReadModelScenarioReducerOrderCreated("order-1"), new TestingReadModelScenarioReducerItemAdded(9.99m), new TestingReadModelScenarioReducerItemAdded(4.50m));
scenario.Instance!.Total.ShouldEqual(14.49m); }}Example: fluent projection
Section titled “Example: fluent projection”using Cratis.Chronicle.Events;using Cratis.Chronicle.Projections;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
[EventType]public record TestingReadModelScenarioFluentProductCreated(string Name);
[EventType]public record TestingReadModelScenarioFluentStockAdjusted(int NewStock);
public record TestingReadModelScenarioFluentProductView(string Name, int Stock);
public class TestingReadModelScenarioFluentProductViewProjection : IProjectionFor<TestingReadModelScenarioFluentProductView>{ public void Define(IProjectionBuilderFor<TestingReadModelScenarioFluentProductView> builder) => builder .From<TestingReadModelScenarioFluentProductCreated>(_ => _ .Set(m => m.Name).To(e => e.Name)) .From<TestingReadModelScenarioFluentStockAdjusted>(_ => _ .Set(m => m.Stock).To(e => e.NewStock));}
public static class TestingReadModelScenarioFluentProjectionExample{ public static async Task Run() { var productId = "product-1"; var scenario = new ReadModelScenario<TestingReadModelScenarioFluentProductView>(); await scenario.Given .ForEventSource(productId) .Events( new TestingReadModelScenarioFluentProductCreated("Widget"), new TestingReadModelScenarioFluentStockAdjusted(100));
scenario.Instance!.Name.ShouldEqual("Widget"); scenario.Instance!.Stock.ShouldEqual(100); }}Example: model-bound projection
Section titled “Example: model-bound projection”using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
[EventType]public record TestingReadModelScenarioModelBoundShipmentDispatched(string Carrier);
[EventType]public record TestingReadModelScenarioModelBoundShipmentDelivered(DateTimeOffset DeliveredAt);
[FromEvent<TestingReadModelScenarioModelBoundShipmentDispatched>][FromEvent<TestingReadModelScenarioModelBoundShipmentDelivered>]public record TestingReadModelScenarioModelBoundDeliveryStatus( [Key] string ShipmentId, string Carrier, DateTimeOffset? DeliveredAt);
public static class TestingReadModelScenarioModelBoundExample{ public static async Task Run() { var shipmentId = "shipment-1"; var scenario = new ReadModelScenario<TestingReadModelScenarioModelBoundDeliveryStatus>(); await scenario.Given .ForEventSource(shipmentId) .Events( new TestingReadModelScenarioModelBoundShipmentDispatched("FedEx"), new TestingReadModelScenarioModelBoundShipmentDelivered(DateTimeOffset.UtcNow));
scenario.Instance!.Carrier.ShouldEqual("FedEx"); scenario.Instance!.DeliveredAt.HasValue.ShouldBeTrue(); }}Child collections
Section titled “Child collections”[ChildrenFrom<TEvent>] child collections are materialized the same way they are at runtime, so a spec can assert the child rows directly on Instance. The child key may be any value the runtime accepts:
string,int/long,bool,enum,Guid- the temporal primitives
DateOnly,TimeOnly,DateTime,DateTimeOffset - a
ConceptAs<T>over any of the above
Both same-event-source children (parent and child events share one event source) and cross-stream children (child events on their own event source, linked with parentKey) are supported, and two events carrying the same child key merge into a single child — matching the real sink.
using System.Linq;using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
[EventType]public record TestingReadModelScenarioChildrenTimesheetStarted(int Year);
[EventType]public record TestingReadModelScenarioChildrenDayRecorded(DateOnly Day, decimal Hours);
public record TestingReadModelScenarioChildrenTimesheetDay(DateOnly Day, decimal Hours);
[FromEvent<TestingReadModelScenarioChildrenTimesheetStarted>]public record TestingReadModelScenarioChildrenTimesheet( [Key] Guid Id, int Year,
[ChildrenFrom<TestingReadModelScenarioChildrenDayRecorded>(key: nameof(TestingReadModelScenarioChildrenDayRecorded.Day))] IEnumerable<TestingReadModelScenarioChildrenTimesheetDay> Days);
public static class TestingReadModelScenarioChildren{ public static async Task Run() { var scenario = new ReadModelScenario<TestingReadModelScenarioChildrenTimesheet>(); await scenario.Given .ForEventSource(Guid.NewGuid()) .Events( new TestingReadModelScenarioChildrenTimesheetStarted(2026), new TestingReadModelScenarioChildrenDayRecorded(new DateOnly(2026, 6, 1), 7.5m), new TestingReadModelScenarioChildrenDayRecorded(new DateOnly(2026, 6, 2), 8m));
scenario.Instance!.Days.Count().ShouldEqual(2); scenario.Instance!.Days.First().Day.ShouldEqual(new DateOnly(2026, 6, 1)); scenario.Instance!.Days.First().Hours.ShouldEqual(7.5m); }}Multiple instances (joins and cross-stream)
Section titled “Multiple instances (joins and cross-stream)”Instance returns a single threaded result, which is ambiguous when events span more than one event source — for example a [Join], whose join-source event materializes a second instance. For those specs, assert against a specific instance with InstanceForEventSourceId, which resolves the intended instance from the sink even when another source’s event was seeded first:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
[EventType]public record TestingReadModelScenarioJoinCustomerRegistered(string Name);
[EventType]public record TestingReadModelScenarioJoinOrderPlaced(string CustomerId, decimal Amount);
[FromEvent<TestingReadModelScenarioJoinOrderPlaced>]public record TestingReadModelScenarioJoinOrder( [Key] string Id, string CustomerId, [Join<TestingReadModelScenarioJoinCustomerRegistered>(on: nameof(CustomerId), eventPropertyName: nameof(TestingReadModelScenarioJoinCustomerRegistered.Name))] string CustomerName, decimal Amount);
public static class TestingReadModelScenarioMultipleInstances{ public static async Task Run() { var scenario = new ReadModelScenario<TestingReadModelScenarioJoinOrder>(); var customerId = EventSourceId.New(); var orderId = EventSourceId.New();
await scenario.Given.ForEventSource(customerId).Events(new TestingReadModelScenarioJoinCustomerRegistered("Ada")); await scenario.Given.ForEventSource(orderId).Events(new TestingReadModelScenarioJoinOrderPlaced(customerId.Value, 100m));
var order = scenario.InstanceForEventSourceId(orderId); order!.CustomerName.ShouldEqual("Ada"); }}Instances exposes every materialized instance keyed by its event source id. Both are populated for projections; reducers are single-instance and expose their result through Instance.
If more than one instance materializes and you read Instance, it throws MultipleInstancesMaterialized rather than silently returning a blended object — a signal to reach for InstanceForEventSourceId or Instances instead.
InstanceisnulluntilGivenis called, or if no events were processed.- Each
Given.ForEventSource(id).Events(...)call applies events to the specified event source. Call it multiple times to simulate events from different event sources. Given.ForEventSourceId(id)is an alias forForEventSource(id)that also exposes.ReadModel(instance)for pre-seeding.- Cratis Specifications provides
ShouldEqual,ShouldBeTrue,ShouldBeNull, and related assertions. If you use Shouldly or FluentAssertions, translate the assertions to your project’s style.