Event Append Collection
IEventAppendCollection is a scoped collector that captures every event appended to the event log
while it is active. It is the primary tool for asserting on appended events in integration tests.
How It Works
Section titled “How It Works”Calling StartCollectingAppends() on the fixture subscribes a new IEventAppendCollection to the
AppendOperations observable on the event log. Every subsequent call to EventStore.EventLog.Append
or EventStore.EventLog.AppendMany is captured automatically and stored in the collection as an
AppendedEventWithResult. This includes events appended by reactors that use
ICommandPipeline.Execute() — because the command pipeline commits its unit of work through the
same AppendMany path, those appends are captured just like direct ones.
Because Append is awaited, the append operation is complete and the event is recorded in the
collection by the time the await returns. When testing reactors that append follow-up events
asynchronously, use WaitForCount() to wait for the expected number of events to arrive.
One-Time Test Project Setup
Section titled “One-Time Test Project Setup”IEventAppendCollection and ChronicleInProcessFixture come from the Cratis.Chronicle.XUnit.Integration
package. Every test project using them needs two small, one-time pieces of boilerplate: an xUnit
collection definition so every test class in the project shares one ChronicleInProcessFixture
(starting the underlying MongoDB container once, not per test class), and a project-local
Specification convenience base that closes the generic Specification<TChronicleFixture> over
your fixture type:
namespace Cratis.Chronicle.Docs.EventAppendCollection{ // One-time test project setup: a collection definition sharing one ChronicleInProcessFixture // across test classes, and a project-local Specification convenience base. [CollectionDefinition(Name)] public class ChronicleCollection : ICollectionFixture<ChronicleInProcessFixture> { public const string Name = "Chronicle"; }
public class Specification(ChronicleInProcessFixture fixture) : Specification<ChronicleInProcessFixture>(fixture) { public override bool AutoDiscoverArtifacts => false; }
[EventType] public record ItemRegistered(string Name);
namespace given { public class an_event_append_collection_scope(ChronicleInProcessFixture fixture) : Specification(fixture) { public EventSourceId EventSourceId = null!; public IEventAppendCollection AppendedEventsCollector = null!;
public override IEnumerable<Type> EventTypes => [typeof(ItemRegistered)];
void Establish() => EventSourceId = EventSourceId.New();
async Task Because() { AppendedEventsCollector = StartCollectingAppends(); await EventStore.EventLog.Append(EventSourceId, new ItemRegistered("Widget")); }
void Destroy() => AppendedEventsCollector?.Dispose(); } }}Every other example on this page builds on this same setup.
Scope Lifetime
Section titled “Scope Lifetime”Create a scope immediately before the operation under test so no earlier events are captured. Dispose
it when the operation is done. The recommended pattern, on a given context class:
namespace Cratis.Chronicle.Docs.EventAppendCollection{ // One-time test project setup: a collection definition sharing one ChronicleInProcessFixture // across test classes, and a project-local Specification convenience base. [CollectionDefinition(Name)] public class ChronicleCollection : ICollectionFixture<ChronicleInProcessFixture> { public const string Name = "Chronicle"; }
public class Specification(ChronicleInProcessFixture fixture) : Specification<ChronicleInProcessFixture>(fixture) { public override bool AutoDiscoverArtifacts => false; }
[EventType] public record ItemRegistered(string Name);
namespace given { public class an_event_append_collection_scope(ChronicleInProcessFixture fixture) : Specification(fixture) { public EventSourceId EventSourceId = null!; public IEventAppendCollection AppendedEventsCollector = null!;
public override IEnumerable<Type> EventTypes => [typeof(ItemRegistered)];
void Establish() => EventSourceId = EventSourceId.New();
async Task Because() { AppendedEventsCollector = StartCollectingAppends(); await EventStore.EventLog.Append(EventSourceId, new ItemRegistered("Widget")); }
void Destroy() => AppendedEventsCollector?.Dispose(); } }}Disposal unsubscribes the collection immediately. Any appends that occur after disposal are not captured, which prevents tests sharing a fixture from interfering with each other.
AppendedEventWithResult Members
Section titled “AppendedEventWithResult Members”Each entry in All is an AppendedEventWithResult record that pairs the appended event with the
full outcome of the operation:
| Member | Type | Description |
|---|---|---|
Event | AppendedEvent | The appended event, including its context and deserialized content |
Event.Content | object | The deserialized event object (cast to your event type for assertions) |
Event.Context | EventContext | Metadata: event source, sequence number, correlation ID, causation chain, etc. |
Event.Context.EventSourceId | EventSourceId | The event source the event was appended for |
Event.Context.SequenceNumber | EventSequenceNumber | Assigned sequence number |
Event.Context.CorrelationId | CorrelationId | Correlation ID active at the time of the append |
Event.Context.Causation | IEnumerable<Causation> | The causation chain active at the time of the append |
Result | AppendResult | The outcome of the append operation |
Result.IsSuccess | bool | true when the sequence number is valid and there are no violations or errors |
Result.SequenceNumber | EventSequenceNumber | Assigned sequence number; EventSequenceNumber.Unavailable when the append failed |
Result.HasConstraintViolations | bool | true when at least one constraint violation was returned |
Result.HasConcurrencyViolations | bool | true when at least one concurrency violation was returned |
Result.HasErrors | bool | true when at least one error was returned |
Result.ConstraintViolations | IEnumerable<ConstraintViolation> | Constraint violations, if any |
Result.ConcurrencyViolation | ConcurrencyViolation? | Concurrency violation, if any |
Result.Errors | IEnumerable<AppendError> | Errors, if any |
Asserting on Collected Events
Section titled “Asserting on Collected Events”IEventAppendCollection exposes three members:
| Member | Description |
|---|---|
All | A snapshot of every AppendedEventWithResult captured so far |
Last | The most recently captured AppendedEventWithResult; throws when nothing has been collected |
WaitForCount(count, timeout?) | Waits asynchronously until at least count events have been collected |
Single event
Section titled “Single event”Pairing the given context above with a Given<TSetup>-derived spec class gives you access to the
collected events through Context:
namespace Cratis.Chronicle.Docs.EventAppendCollection{ [Collection(ChronicleCollection.Name)] public class and_collecting_the_registered_item(given.an_event_append_collection_scope context) : Given<given.an_event_append_collection_scope>(context) { [Fact] void should_collect_one_event() => Context.AppendedEventsCollector.All.Count.ShouldEqual(1); [Fact] void should_have_appended_the_event() => Context.AppendedEventsCollector.All[0].Event.Content.ShouldBeOfExactType<ItemRegistered>(); [Fact] void should_be_successful() => Context.AppendedEventsCollector.All[0].Result.IsSuccess.ShouldBeTrue(); [Fact] void should_have_a_valid_sequence_number() => Context.AppendedEventsCollector.All[0].Result.SequenceNumber.IsActualValue.ShouldBeTrue(); }}Multiple events
Section titled “Multiple events”When several events land in the same collection scope, use LINQ to locate the one you want:
namespace Cratis.Chronicle.Docs.EventAppendCollection{ [EventType] public record FirstItemAdded(string Name);
[EventType] public record SecondItemAdded(string Name);
namespace given { public class a_multiple_events_scope(ChronicleInProcessFixture fixture) : Specification(fixture) { public EventSourceId EventSourceId = null!; public IEventAppendCollection AppendedEventsCollector = null!;
public override IEnumerable<Type> EventTypes => [typeof(FirstItemAdded), typeof(SecondItemAdded)];
void Establish() => EventSourceId = EventSourceId.New();
async Task Because() { AppendedEventsCollector = StartCollectingAppends(); await EventStore.EventLog.Append(EventSourceId, new FirstItemAdded("Widget")); await EventStore.EventLog.Append(EventSourceId, new SecondItemAdded("Gadget")); }
void Destroy() => AppendedEventsCollector?.Dispose(); } }
[Collection(ChronicleCollection.Name)] public class and_locating_the_second_event(given.a_multiple_events_scope context) : Given<given.a_multiple_events_scope>(context) { AppendedEventWithResult SecondEvent => Context.AppendedEventsCollector.All.First(e => e.Event.Content is SecondItemAdded);
[Fact] void should_locate_the_second_event() => SecondEvent.Event.Content.ShouldBeOfExactType<SecondItemAdded>(); [Fact] void should_carry_the_correct_name() => ((SecondItemAdded)SecondEvent.Event.Content).Name.ShouldEqual("Gadget"); }}Waiting for Asynchronous Appends
Section titled “Waiting for Asynchronous Appends”When a reactor appends events after handling an incoming event, those appends happen asynchronously
on the server. Use WaitForCount() to wait for the expected number of events to arrive before
asserting. The events and reactor:
namespace Cratis.Chronicle.Docs.EventAppendCollection{ [EventType] public record OrderPlaced(string OrderId);
[EventType] public record ShipmentScheduled(string OrderId);
public class ShipmentReactor(IEventLog eventLog) : IReactor { public Task OnOrderPlaced(OrderPlaced evt, EventContext ctx) => eventLog.Append(ctx.EventSourceId, new ShipmentScheduled(evt.OrderId)); }}The shared given context:
namespace Cratis.Chronicle.Docs.EventAppendCollection{ namespace given { public class a_shipment_reactor_context(ChronicleInProcessFixture fixture) : Specification(fixture) { public EventSourceId EventSourceId = null!; public IEventAppendCollection AppendedEventsCollector = null!;
public override IEnumerable<Type> EventTypes => [typeof(OrderPlaced), typeof(ShipmentScheduled)]; public override IEnumerable<Type> Reactors => [typeof(ShipmentReactor)];
protected override void ConfigureServices(IServiceCollection services) => services.AddSingleton<ShipmentReactor>();
void Establish() => EventSourceId = EventSourceId.New();
void Destroy() => AppendedEventsCollector?.Dispose(); } }}The spec, waiting for both the original event and the reactor’s follow-up to be captured:
namespace Cratis.Chronicle.Docs.EventAppendCollection{ [Collection(ChronicleCollection.Name)] public class and_collecting_the_scheduled_shipment(and_collecting_the_scheduled_shipment.context context) : Given<and_collecting_the_scheduled_shipment.context>(context) {#pragma warning disable CS8981 // "context" is a conventional, lowercase BDD nested-class name public class context(ChronicleInProcessFixture fixture) : given.a_shipment_reactor_context(fixture) { async Task Because() { var reactor = EventStore.Reactors.GetHandlerFor<ShipmentReactor>(); await reactor.WaitTillActive();
AppendedEventsCollector = StartCollectingAppends(); await EventStore.EventLog.Append(EventSourceId, new OrderPlaced("order-123"));
// Wait for the reactor's follow-up append to arrive await AppendedEventsCollector.WaitForCount(2); } }#pragma warning restore CS8981
AppendedEventWithResult Shipment => Context.AppendedEventsCollector.All .First(e => e.Event.Content is ShipmentScheduled);
[Fact] void should_schedule_a_shipment() => Shipment.Event.Content.ShouldBeOfExactType<ShipmentScheduled>(); [Fact] void should_carry_the_order_id() => ((ShipmentScheduled)Shipment.Event.Content).OrderId.ShouldEqual("order-123"); [Fact] void should_be_successful() => Shipment.Result.IsSuccess.ShouldBeTrue(); [Fact] void should_have_a_valid_sequence_number() => Shipment.Result.SequenceNumber.IsActualValue.ShouldBeTrue(); }}WaitForCount accepts an optional TimeSpan timeout (default: 5 seconds) and throws
TimeoutException if the expected count is not reached in time. WaitTillActive() ensures the
reactor is registered and listening on the server before the test appends the triggering event.
Checking Violations
Section titled “Checking Violations”When a reactor appends directly and the append is rejected by a constraint, the violation is captured
on the AppendedEventWithResult.Result instead of throwing. The events, constraint, and reactor:
namespace Cratis.Chronicle.Docs.EventAppendCollection{ [EventType] public record UniqueValueRecorded(string UniqueValue);
[EventType] public record UniqueValueFollowUp(string UniqueValue);
public class UniqueValueFollowUpConstraint : IConstraint { public void Define(IConstraintBuilder builder) => builder.Unique(b => b.On<UniqueValueFollowUp>(e => e.UniqueValue)); }
public class UniqueValueReactor(IEventLog eventLog) : IReactor { public Task OnUniqueValueRecorded(UniqueValueRecorded evt, EventContext ctx) => eventLog.Append(ctx.EventSourceId, new UniqueValueFollowUp(evt.UniqueValue)); }}The shared given context:
namespace Cratis.Chronicle.Docs.EventAppendCollection{ namespace given { public class a_unique_value_reactor_context(ChronicleInProcessFixture fixture) : Specification(fixture) { public IEventAppendCollection AppendedEventsCollector = null!;
public override IEnumerable<Type> EventTypes => [typeof(UniqueValueRecorded), typeof(UniqueValueFollowUp)]; public override IEnumerable<Type> ConstraintTypes => [typeof(UniqueValueFollowUpConstraint)]; public override IEnumerable<Type> Reactors => [typeof(UniqueValueReactor)];
protected override void ConfigureServices(IServiceCollection services) => services.AddSingleton<UniqueValueReactor>();
void Destroy() => AppendedEventsCollector?.Dispose(); } }}The spec appends the same unique value from two different event sources — the reactor’s second
follow-up violates the uniqueness constraint, so HasConstraintViolations is true and IsSuccess
is false for that entry, while the append itself still completes and is captured in the collection:
namespace Cratis.Chronicle.Docs.EventAppendCollection{ [Collection(ChronicleCollection.Name)] public class and_a_reactor_directly_appends_a_duplicate_unique_value(and_a_reactor_directly_appends_a_duplicate_unique_value.context context) : Given<and_a_reactor_directly_appends_a_duplicate_unique_value.context>(context) {#pragma warning disable CS8981 // "context" is a conventional, lowercase BDD nested-class name public class context(ChronicleInProcessFixture fixture) : given.a_unique_value_reactor_context(fixture) { public string UniqueValue = null!;
async Task Because() { var reactor = EventStore.Reactors.GetHandlerFor<UniqueValueReactor>(); await reactor.WaitTillActive();
UniqueValue = Guid.NewGuid().ToString(); var firstEventSourceId = EventSourceId.New(); var secondEventSourceId = EventSourceId.New();
AppendedEventsCollector = StartCollectingAppends(); await EventStore.EventLog.Append(firstEventSourceId, new UniqueValueRecorded(UniqueValue)); await EventStore.EventLog.Append(secondEventSourceId, new UniqueValueRecorded(UniqueValue)); await AppendedEventsCollector.WaitForCount(4); } }#pragma warning restore CS8981
AppendedEventWithResult ViolatingAppend => Context.AppendedEventsCollector.All .First(e => e.Result.HasConstraintViolations);
[Fact] void should_have_a_constraint_violation() => ViolatingAppend.Result.HasConstraintViolations.ShouldBeTrue(); [Fact] void should_not_be_successful() => ViolatingAppend.Result.IsSuccess.ShouldBeFalse(); [Fact] void should_have_attempted_the_follow_up_event() => ViolatingAppend.Event.Content.ShouldBeOfExactType<UniqueValueFollowUp>(); }}Full Example
Section titled “Full Example”The Waiting for Asynchronous Appends example above is itself a complete, runnable integration test
that verifies a reactor that listens for OrderPlaced and appends a ShipmentScheduled follow-up
event — the three tabs together (events and reactor, given context, and spec) are everything you
need in a real test project.