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.
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
ReadModelScenario<TReadModel> is in the Cratis.Chronicle.Testing NuGet package:
dotnet add package Cratis.Chronicle.Testing
Basic usage
var scenario = new ReadModelScenario<MyReadModel>();
await scenario.Given
.ForEventSource(myId)
.Events(new SomeEvent("value"), new SomeOtherEvent(42));
scenario.Instance.SomeProperty.ShouldBe("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
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.ShouldBe(11);
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.ShouldBe(0m);
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 test
var sut = new OrderService(scenario.ReadModels);
var result = await sut.GetOrderTotal("order-1");
result.ShouldBe(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
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
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.ShouldBe(14.49m);
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.ShouldBe("Widget");
scenario.Instance!.Stock.ShouldBe(100);
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.ShouldBe("FedEx");
scenario.Instance!.DeliveredAt.ShouldNotBeNull();
Notes
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.- The
.ShouldBe()assertions in the examples come from your test framework (e.g., Cratis.Specifications, Shouldly, or FluentAssertions).