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”public class when_projecting_events : Specification{ readonly EventSourceId _eventSourceId = EventSourceId.New(); readonly ReadModelScenario<MyReadModel> _scenario = new();
Task Because() => _scenario.Given .ForEventSource(_eventSourceId) .Events(new SomeEvent("value"), new SomeOtherEvent(42));
[Fact] void should_project_the_value() => _scenario.Instance!.SomeProperty.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:
var initial = new MyReadModel { Count = 10 };var scenario = new ReadModelScenario<MyReadModel>(initial);await scenario.Given .ForEventSource(myId) .Events(new ItemAdded());
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:
var pricingService = Substitute.For<IPricingService>();var services = new ServiceCollection() .AddSingleton(pricingService) .BuildServiceProvider();
var scenario = new ReadModelScenario<OrderSummary>(initialState: null, serviceProvider: services);await scenario.Given .ForEventSource("order-1") .Events(new OrderCreated("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:
var scenario = new ReadModelScenario<OrderSummary>();await scenario.Given .ForEventSourceId("order-1") .ReadModel(new OrderSummary("order-1", 99.99m));
// Pass scenario.ReadModels to production code under testvar sut = new OrderService(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”public record OrderSummary(string OrderId, decimal Total);
public class OrderSummaryReducer : IReducerFor<OrderSummary>{ public OrderSummary OnOrderCreated(OrderCreated @event, OrderSummary? current, EventContext context) => new(@event.OrderId, 0m);
public OrderSummary OnItemAdded(ItemAdded @event, OrderSummary current, EventContext context) => current with { Total = current.Total + @event.Price };}
// In your spec:var orderId = "order-1";var scenario = new ReadModelScenario<OrderSummary>();await scenario.Given .ForEventSource(orderId) .Events( new OrderCreated("order-1"), new ItemAdded(9.99m), new ItemAdded(4.50m));
scenario.Instance!.Total.ShouldEqual(14.49m);Example: fluent projection
Section titled “Example: fluent projection”[ReadModel]public record ProductView(string Name, int Stock);
public class ProductViewProjection : IProjectionFor<ProductView>{ public void Define(IProjectionBuilderFor<ProductView> builder) => builder .From<ProductCreated>(_ => _ .Set(m => m.Name).To(e => e.Name)) .From<StockAdjusted>(_ => _ .Set(m => m.Stock).To(e => e.NewStock));}
// In your spec:var productId = "product-1";var scenario = new ReadModelScenario<ProductView>();await scenario.Given .ForEventSource(productId) .Events( new ProductCreated("Widget"), new StockAdjusted(100));
scenario.Instance!.Name.ShouldEqual("Widget");scenario.Instance!.Stock.ShouldEqual(100);Example: model-bound projection
Section titled “Example: model-bound projection”[ReadModel]public record DeliveryStatus( [Key] string ShipmentId, [FromEvent<ShipmentDispatched>] string Carrier, [FromEvent<ShipmentDelivered>] DateTimeOffset? DeliveredAt);
// In your spec:var shipmentId = "shipment-1";var scenario = new ReadModelScenario<DeliveryStatus>();await scenario.Given .ForEventSource(shipmentId) .Events( new ShipmentDispatched("FedEx"), new ShipmentDelivered(DateTimeOffset.UtcNow));
scenario.Instance!.Carrier.ShouldEqual("FedEx");scenario.Instance!.DeliveredAt.ShouldNotBeNull();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.