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. Only what you registered comes
back from it: the rest of that surface is the production one and wants a running Chronicle, so reading the
instances the scenario itself materialized means Instances or InstanceForEventSourceId, not GetInstances.
Auto-detection
Section titled “Auto-detection”ReadModelScenario<TReadModel> looks up a handler in the client artifacts registry — the same reflection-based
discovery Chronicle itself uses, over the project-referenced and package-referenced assemblies, resolved once per
process — 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.
Asking what Chronicle registered
Section titled “Asking what Chronicle registered”That registry is not private to the scenario. scenario.ClientArtifactsProvider hands out the very
IClientArtifactsProvider the scenario resolved its handler from, so a spec can ask what Chronicle registered
instead of re-deriving it by reflecting over its own assemblies:
using Cratis.Chronicle.Testing;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;
public static class TestingReadModelScenarioRegisteredArtifacts{ public static void Run() { var scenario = new ReadModelScenario<TestingReadModelScenarioModelBoundDeliveryStatus>();
// The registry Chronicle discovered its artifacts from - no reflection of your own needed var artifacts = scenario.ClientArtifactsProvider; artifacts.ModelBoundProjections.ShouldContain(typeof(TestingReadModelScenarioModelBoundDeliveryStatus)); artifacts.EventTypes.ShouldContain(typeof(TestingReadModelScenarioModelBoundShipmentDispatched));
// The same registry, reachable without a scenario Defaults.Instance.ClientArtifactsProvider.Reactors.ShouldNotBeNull(); }}It exposes every artifact kind Chronicle discovers — EventTypes, Projections, ModelBoundProjections,
Reactors, ReadModelReactors, Reducers, ReactorMiddlewares, ConstraintTypes, UniqueConstraints,
UniqueEventTypeConstraints, RemoveConstraintEventTypes, EventTypeMigrators, EventSeeders, and the
compliance and additional-event-information providers — including the classifications the registry draws
itself. An event type with a property-level [Unique] lands in UniqueConstraints, while one with a
class-level [Unique] lands in UniqueEventTypeConstraints; asking the registry gets that distinction right
without reimplementing it.
The property is read-only: reading it neither triggers nor alters registration, and every read hands out the same instance.
Strict event subscription
Section titled “Strict event subscription”By default the scenario mirrors the production projection engine, which filters an event source’s stream down to the types the projection subscribes to — so seeding an unrelated audit or marker event is silently ignored. When you would rather be told, WithStrictEventSubscription() turns that silent skip into an UnsubscribedEventSeeded naming the offending event type:
var scenario = new ReadModelScenario<OrderSummary>().WithStrictEventSubscription();This applies to projection-backed read models. A reducer only invokes the handlers it declares, so an unsubscribed seeded event is inherently a no-op there, exactly as at runtime.
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.
What the harness stands in for
Section titled “What the harness stands in for”Running in-process is what makes this tier fast enough to use on every build, and the price is that some of what a deployed Chronicle does is stood in for. None of it is a defect — but a spec whose subject lives in a substituted layer passes just as confidently as one whose subject does not, so it is worth knowing which is which before you rely on a green.
These are substituted for every scenario, whatever your read model looks like:
| Layer | What runs instead |
|---|---|
| The read model sink | An in-memory sink. Documents are held as objects and round-tripped through JSON, never through the encoding a real store uses. |
| Storage and the observer lifecycle | Nothing at all. There are no observers, no partitions, no failed-partition state, and no replay or rewind. |
| The event context | The sequence number is the event’s position in what you seeded, and Occurred is a fixed base plus one tick per event. A reducer gets a context built differently again, with no Occurred. |
| The document key mapping | The _id ↔ identifier-property mapping a store performs is applied in C# instead. |
| The read model definition | Identifier, container and display name come straight off the CLR type and there are no indexes, where a deployed Chronicle uses your naming policy and the indexes you declared. |
| The client read surface | scenario.ReadModels is the production client surface; its read side wants a running Chronicle and refuses rather than answering. Read what the scenario materialized through Instance, Instances or InstanceForEventSourceId. |
Three more are substituted only for certain shapes — and because the harness can see which of those shapes your read model has, it tells you.
Asking what a scenario substituted
Section titled “Asking what a scenario substituted”Substitutions reports the substituted layers your read model’s own shape reaches. It is derived from the read model and its projection alone, so you can read it before seeding anything:
var scenario = new ReadModelScenario<OrderSummary>();
foreach (var substitution in scenario.Substitutions){ Console.WriteLine($"{substitution.Layer}: {substitution.Shape} — {substitution.Consequence}");}Layer | Reported when |
|---|---|
Sink | the document key is not a Guid, or the read model carries a class-level [RemovedWith] |
JoinKeyResolution | the read model or one of its child collections carries a [Join] |
DeferredKeyHandling | a child collection takes its parent key from an event property rather than from the event source id |
An empty list means nothing shape-dependent is being stood in for. Anything in the list is a claim your in-process spec cannot settle on its own — cover it against a real kernel and a real store as well.
To make that binding rather than advisory, opt in to strict fidelity. A read model that reaches a substituted layer then raises ReadModelDependsOnSubstitutedLayer instead of returning a result, so a suite can turn it on everywhere and have only the shapes that genuinely need a second tier change color:
var scenario = new ReadModelScenario<OrderSummary>().WithStrictFidelity();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.