Skip to content

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.

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.

ReadModelScenario<TReadModel> is in the Cratis.Chronicle.Testing NuGet package:

Terminal window
dotnet add package Cratis.Specifications.XUnit
dotnet add package Cratis.Chronicle.Testing
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.

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

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

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 test
var 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.

ReadModelScenario<TReadModel> searches the current application’s loaded assemblies for a handler in this order:

  1. Reducer — a class implementing IReducerFor<TReadModel>.
  2. Fluent projection — a class implementing IProjectionFor<TReadModel>.
  3. Model-bound projectionTReadModel itself carries [FromEvent<T>] or [Key] attributes.

If none are found, NoReadModelHandlerFound is thrown.

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);
[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);
[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();
  • Instance is null until Given is 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 for ForEventSource(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.