Chronicle read-model specifications
ReadModelScenario<TReadModel> runs a projection or a reducer in-process
against a sequence of events. No Chronicle server, no database, no network — the
events go in, the materialized instance comes out.
For a read-model specification the Given is the act. There is no
separate When: seeding the events is what makes the projection run.
Verified product sources
| Package | Version | Purpose |
|---|---|---|
Cratis.Chronicle.Testing |
16.45.2 |
ReadModelScenario<TReadModel> and its Given builder |
Cratis.Specifications.XUnit |
4.x |
The Specification base and the ShouldXxx assertions |
dotnet add package Cratis.Chronicle.Testing
Reverify against the Chronicle repository before claiming support for another version.
Route near misses
- Event appending, constraints, or the event log's own behavior: use
cratis-chronicle-event-specifications. - A command running through validators,
Provide()andHandle(): usecratis-application-slice-specifications. - Deciding what the read model or projection should be: use
cratis-chronicle-read-model,cratis-chronicle-projection, orcratis-chronicle-reducer.
When you need this
- A reducer builds state from a sequence of events.
- A fluent
IProjectionFor<T>maps event properties onto read-model properties. - A model-bound projection —
[FromEvent<T>],[SetFrom<T>],[Key]— maps correctly. - A boundary matters: the first event, several events, several event sources.
Step 1 — Create the scenario
A new scenario per specification, never shared state.
// Default, empty initial state
var scenario = new ReadModelScenario<MyReadModel>();
// With a baseline
var scenario = new ReadModelScenario<CartSummary>(new CartSummary { ItemCount = 10 });
Further constructors take a service provider, a Defaults, or both, alongside
the initial state. Use the named-parameter form when you need only one of them:
new ReadModelScenario<T>(initialState: null, serviceProvider: services).
Step 2 — Seed the events
await scenario.Given
.ForEventSource(myId)
.Events(new SomeEvent("value"), new SomeOtherEvent(42));
Chain a second ForEventSource for another event source — a cross-stream
projection is seeded by giving each contributing stream its own call:
await scenario.Given.ForEventSource(orderId).Events(new OrderCreated("order-1"), new ItemAdded(9.99m));
await scenario.Given.ForEventSource(anotherOrderId).Events(new OrderCreated("order-2"));
ForEventSourceId(...) is an alias for ForEventSource(...). The builder also
offers .ReadModel(instance) to pin a materialized instance for code that calls
IReadModels.GetInstanceById rather than deriving it from events.
Events are processed in the order supplied. Seed before asserting, and do not
try to separate a "setup" Given from an "act" Given — for a read model they
are the same phase.
Step 3 — Assert on the instance
_scenario.Instance!.Total.ShouldEqual(14.49m);
_scenario.Instance!.Name.ShouldEqual("Widget");
Instancematerializes lazily on first access and isnullwhen nothing was produced. It throwsMultipleInstancesMaterializedwhen the seeded events produced more than one — a single answer would be ambiguous.Instancesis the whole dictionary keyed by event-source id, andInstanceForEventSourceId(id)picks one. Use those when the specification deliberately seeds several sources.- There are no
Should*assertions onReadModelScenario, as members or as extensions. Assert onInstancewith the ordinaryShouldXxxassertions — and note the shipped name isShouldEqual, notShouldBe.
Do not pre-emptively skip an assertion. Assume scalar concept, enum, and identifier properties populate; when one does not, investigate the projection rather than the harness. Skip only on a reproduced harness gap, and put the specific reason in the skip message.
Step 4 — Know what the scenario picked
The scenario finds a handler for TReadModel in this order:
- A reducer — a class implementing
IReducerFor<TReadModel>. - A fluent projection — a class implementing
IProjectionFor<TReadModel>. - Model-bound projection attributes on
TReadModelitself. - A separate model-bound projection type registered for it.
If none is found it throws NoReadModelHandlerFound. When a read model has both
a reducer and a projection, the reducer wins — which is worth knowing when a
specification exercises a path the running application does not.
For stricter runs, WithStrictEventSubscription() and WithStrictFidelity()
turn silent mismatches into failures.
Step 5 — Write the specification
#if DEBUG
namespace MyApp.Ordering.Orders.when_items_are_added;
public class and_two_items_are_priced : Specification
{
ReadModelScenario<OrderSummary> _scenario;
static readonly OrderId TheOrder = OrderId.New();
async Task Establish()
{
_scenario = new ReadModelScenario<OrderSummary>();
await _scenario.Given
.ForEventSource(TheOrder)
.Events(
new OrderCreated(),
new ItemAdded(9.99m),
new ItemAdded(4.50m));
}
[Fact] void should_sum_the_item_prices() => _scenario.Instance!.Total.ShouldEqual(14.49m);
}
#endif
Wrap every file in #if DEBUG … #endif so specification code ships only in
Debug, and keep one outcome per should_ fact.
What breaks
NoReadModelHandlerFound. Nothing in the loaded assemblies handlesTReadModel— the reducer or projection type is not where the scenario looks.MultipleInstancesMaterializedonInstance. The seeded events produced more than one instance. UseInstanceForEventSourceId(id), or seed one source.Instanceisnull. No event in the seeded set matched the projection, so nothing was created. Check the key resolution before the property mapping.- A property is silently the type default. AutoMap wired a different event's identically named property over the explicit setter, or the explicit setter never fired. This looks like a specification problem and is a projection problem.
- The specification passes but production does not. The specification pinned a read-model instance instead of seeding events, so the projection under question never ran.
How it is proven
dotnet build in Debug and dotnet test, both clean. A projection
specification is only meaningful once it has been seen to fail: drop one seeded
event and confirm the assertion goes red before trusting it green.