Skip to content

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.

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.

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.

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.

Each entry in All is an AppendedEventWithResult record that pairs the appended event with the full outcome of the operation:

MemberTypeDescription
EventAppendedEventThe appended event, including its context and deserialized content
Event.ContentobjectThe deserialized event object (cast to your event type for assertions)
Event.ContextEventContextMetadata: event source, sequence number, correlation ID, causation chain, etc.
Event.Context.EventSourceIdEventSourceIdThe event source the event was appended for
Event.Context.SequenceNumberEventSequenceNumberAssigned sequence number
Event.Context.CorrelationIdCorrelationIdCorrelation ID active at the time of the append
Event.Context.CausationIEnumerable<Causation>The causation chain active at the time of the append
ResultAppendResultThe outcome of the append operation
Result.IsSuccessbooltrue when the sequence number is valid and there are no violations or errors
Result.SequenceNumberEventSequenceNumberAssigned sequence number; EventSequenceNumber.Unavailable when the append failed
Result.HasConstraintViolationsbooltrue when at least one constraint violation was returned
Result.HasConcurrencyViolationsbooltrue when at least one concurrency violation was returned
Result.HasErrorsbooltrue when at least one error was returned
Result.ConstraintViolationsIEnumerable<ConstraintViolation>Constraint violations, if any
Result.ConcurrencyViolationConcurrencyViolation?Concurrency violation, if any
Result.ErrorsIEnumerable<AppendError>Errors, if any

IEventAppendCollection exposes three members:

MemberDescription
AllA snapshot of every AppendedEventWithResult captured so far
LastThe most recently captured AppendedEventWithResult; throws when nothing has been collected
WaitForCount(count, timeout?)Waits asynchronously until at least count events have been collected

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

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

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.

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

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.