Skip to content

EventScenario

EventScenario is a lightweight, in-process test utility for exercising code that appends events to an IEventLog or IEventSequence. It runs entirely in memory with no Chronicle server, database, or network required.

In a Cratis Specification, EventScenario.Given is the given part of the spec, EventScenario.When is the when — it appends the acted event(s) and returns the AppendResult — and the assertions on that result or the event sequence are the then. You can still call EventLog.Append / EventSequence.Append directly when you need the other overload parameters.

Chronicle integration tests against a live server are accurate but slow and require infrastructure. EventScenario lets you verify that your domain code appends the right events, handles constraint violations correctly, and reacts to pre-seeded state — all in a fast, isolated, and infrastructure-free way.

EventScenario is in the Cratis.Chronicle.Testing NuGet package:

Terminal window
dotnet add package Cratis.Specifications.XUnit
dotnet add package Cratis.Chronicle.Testing
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Testing.EventSequences;
[EventType]
public record TestingScenarioAuthorRegistered(string Name);
[EventType]
public record TestingScenarioBookAdded(string Title);
public static class TestingScenarioBasic
{
public static async Task Run()
{
var scenario = new EventScenario();
var result = await scenario.EventLog.Append(EventSourceId.New(), new TestingScenarioAuthorRegistered("John Doe"));
result.ShouldBeSuccessful();
}
}

View C# snippet source on GitHub

EventLog and EventSequence are backed by the same in-memory store. Use EventLog for domain tests (it maps to EventSequenceId.Log). Use EventSequence when you need a generic IEventSequence reference.

Use the fluent Given builder to put the in-memory event log into a known state before running the act phase:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Testing.EventSequences;
public static class TestingScenarioGiven
{
public static async Task Run()
{
var authorId = EventSourceId.New();
var scenario = new EventScenario();
await scenario.Given
.ForEventSource(authorId)
.Events(new TestingScenarioAuthorRegistered("John Doe"), new TestingScenarioBookAdded("Clean Code"));
var result = await scenario.EventLog.Append(authorId, new TestingScenarioBookAdded("The Pragmatic Programmer"));
result.ShouldBeSuccessful();
}
}

View C# snippet source on GitHub

Chain multiple ForEventSource calls to seed events for different event sources in the same scenario:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Testing.EventSequences;
public static class TestingScenarioGivenMultipleSources
{
public static async Task Run()
{
var author1Id = EventSourceId.New();
var author2Id = EventSourceId.New();
var scenario = new EventScenario();
await scenario.Given
.ForEventSource(author1Id)
.Events(new TestingScenarioAuthorRegistered("Jane Smith"));
await scenario.Given
.ForEventSource(author2Id)
.Events(new TestingScenarioAuthorRegistered("John Doe"));
}
}

View C# snippet source on GitHub

Rules:

  • Call Given before the act phase — seeded events get monotonically increasing sequence numbers.
  • Do not put the act under test inside Given; only pre-existing state goes here.

When mirrors Given: where Given seeds pre-existing state, When performs the act under test. It appends the event(s) through the same in-memory kernel grain and returns the resulting AppendResult — the same “the act returns its result” shape as CommandScenario.Execute — so a constraint or append spec reads with Given / When / then symmetry without binding the raw Append overload by hand:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
using Cratis.Chronicle.Testing.EventSequences;
[EventType]
public record TestingScenarioWhenAuthorRegistered([property: Unique(name: "TestingScenarioUniqueAuthorName")] string Name);
public static class TestingScenarioWhen
{
public static async Task Run()
{
var existingAuthorId = EventSourceId.New();
var newAuthorId = EventSourceId.New();
var scenario = new EventScenario();
// Given: an author with this name is already registered
await scenario.Given
.ForEventSource(existingAuthorId)
.Events(new TestingScenarioWhenAuthorRegistered("John Doe"));
// When: attempt to register the same name under a new event source — When returns the AppendResult
var result = await scenario.When
.ForEventSource(newAuthorId)
.Events(new TestingScenarioWhenAuthorRegistered("John Doe"));
// Then: assert on the returned result
result.ShouldHaveConstraintViolation("TestingScenarioUniqueAuthorName");
}
}

View C# snippet source on GitHub

The returned value is an AppendResult, so every append assertion is available on it. When you supply more than one event to a single When, each is appended in order and the result of the final append is returned.

Use scenario.ReadModels to read projections and reducers built from the events you seed with Given, append with When, or append directly through EventLog. The read models share the scenario’s own event storage, including the events needed to resolve child and join keys. Do not use Defaults.Instance.EventStore.ReadModels for these reads: it belongs to a different store.

For example, suppose your discovered ProjectSummary projects ProjectOpened and ProjectRenamed. In an existing specification with an EventScenario scenario and an EventSourceId projectId:

  1. Seed the project with await scenario.Given.ForEventSource(projectId).Events(new ProjectOpened("Original"));.
  2. Act with await scenario.When.ForEventSource(projectId).Events(new ProjectRenamed("Renamed"));.
  3. Read its current state with var project = await scenario.ReadModels.GetInstanceById<ProjectSummary>(projectId.Value);. The projected name is now Renamed. Use await scenario.ReadModels.GetInstances<ProjectSummary>() to read the collection instead.

A model-bound parent with a [ChildrenFrom<T>] collection works the same way. Seed the parent and append each child to its own event source, supplying the parent identifier in the child event’s configured parentKey property. Reading the parent returns both children with their distinct keys.

To observe the collection, import System.Reactive.Linq and subscribe, for example with using var subscription = scenario.ReadModels.Observe<ProjectSummary>().Subscribe(projects => Console.WriteLine(projects.Count()));. The subscription receives the initial collection and receives it again after a successful append changes the result. Await an emission signal in a specification rather than sleeping before an assertion. Passive read models and event-target projections cannot be observed.

These reads use the in-process projection and reducer processors, not a running observer or a production read-model sink. They do not cover production sink encryption or observer lifecycle. Use ReadModelScenario to specify a read model’s transformations directly.

AppendMany appends a collection of events in one call and returns an AppendManyResult:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Testing.EventSequences;
[EventType]
public record TestingScenarioItemAddedToCart(string ItemId);
[EventType]
public record TestingScenarioItemQuantityAdjusted(string ItemId, int Quantity);
public static class TestingScenarioAppendMany
{
public static async Task Run()
{
var cartId = EventSourceId.New();
var itemId1 = "item-1";
var itemId2 = "item-2";
var scenario = new EventScenario();
var result = await scenario.EventLog.AppendMany(cartId, [
new TestingScenarioItemAddedToCart(itemId1),
new TestingScenarioItemAddedToCart(itemId2),
new TestingScenarioItemQuantityAdjusted(itemId1, 3)
]);
result.ShouldBeSuccessful();
}
}

View C# snippet source on GitHub

EventScenario runs the same closed-stream check as the kernel. A scope closed with EventSequence.CompleteStream, or by an event type declared with [ClosesStream], rejects later appends to it with a constraint violation named closed-stream.

Closing event types are found by the same discovery as other constraints. Append the closing event and assert on the rejection of a covered event:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
using Cratis.Chronicle.Testing.EventSequences;
[EventType]
[ClosesStream(EventStreamIdFrom = nameof(Period), ReopenedBy = [typeof(TestingScenarioBooksReopened)])]
public record TestingScenarioBooksClosed(string Period);
[EventType]
public record TestingScenarioBooksReopened(string Period);
[EventType]
public record TestingScenarioEntryPosted(string Description);
public static class TestingScenarioClosingEvent
{
public static async Task Run()
{
using var scenario = new EventScenario();
var closing = await scenario.EventLog.Append("ledger-1", new TestingScenarioBooksClosed("2026-04"));
closing.ShouldBeSuccessful();
// Covered: same event source, same stream type, stream id taken from the closing event's Period.
var late = await scenario.EventLog.Append(
"ledger-1",
new TestingScenarioEntryPosted("Late invoice"),
"ledger",
"2026-04");
late.ShouldHaveConstraintViolation("closed-stream");
// Not covered: another event source, and the next period.
(await scenario.EventLog.Append("ledger-2", new TestingScenarioEntryPosted("Open"), "ledger", "2026-04")).ShouldBeSuccessful();
(await scenario.EventLog.Append("ledger-1", new TestingScenarioEntryPosted("Next"), "ledger", "2026-05")).ShouldBeSuccessful();
}
}

View C# snippet source on GitHub

Seed a closed state with Given, append the reopening event with When, and assert that the scope accepts events again:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
using Cratis.Chronicle.Testing.EventSequences;
[EventType]
[ClosesStream(EventStreamIdFrom = nameof(Period), ReopenedBy = [typeof(TestingScenarioReopeningBooksReopened)])]
public record TestingScenarioReopeningBooksClosed(string Period);
[EventType]
public record TestingScenarioReopeningBooksReopened(string Period);
[EventType]
public record TestingScenarioReopeningEntryPosted(string Description);
public static class TestingScenarioReopeningEvent
{
public static async Task Run()
{
using var scenario = new EventScenario();
await scenario.Given
.ForEventSource("ledger-1")
.Events(new TestingScenarioReopeningBooksClosed("2026-04"));
// The reopening fact is accepted inside the scope it reopens.
var reopened = await scenario.When
.ForEventSource("ledger-1")
.Events(new TestingScenarioReopeningBooksReopened("2026-04"));
reopened.ShouldBeSuccessful();
var posted = await scenario.EventLog.Append(
"ledger-1",
new TestingScenarioReopeningEntryPosted("Correction"),
"ledger",
"2026-04");
posted.ShouldBeSuccessful();
}
}

View C# snippet source on GitHub

Manual closure goes through EventSequence. GetClosedStreams shows what is closed and by whom:

using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Testing.EventSequences;
[EventType]
public record TestingScenarioManualCloseReportEdited(string Text);
public static class TestingScenarioManualClose
{
public static async Task Run()
{
using var scenario = new EventScenario();
var scope = new ClosedStreamScope(EventSourceId: "report-1", EventStreamId: "2026-04");
var closed = await scenario.EventSequence.CompleteStream(scope);
Console.WriteLine($"Closed: {closed.IsSuccess}");
var edit = await scenario.EventLog.Append(
"report-1",
new TestingScenarioManualCloseReportEdited("Late edit"),
"monthly-report",
"2026-04");
edit.ShouldHaveConstraintViolation("closed-stream");
var closedStreams = await scenario.EventSequence.GetClosedStreams();
Console.WriteLine($"Closed scopes: {closedStreams.Count}");
}
}

View C# snippet source on GitHub

Within one AppendMany, a covered event after a closing event is refused, and close, reopen, then a covered event succeeds. A manual closure on the same scope also refuses the reopening event, because a ReopenedBy event only lifts its own constraint’s closure. See Closing streams for the rules.

A [PII] value on an appended event passes through the kernel’s encryption handler before entering in-memory event storage. Reads release that protection using the same scenario-local subject keys. The schema comes from discovered compliance metadata providers and the stored event generation, not the latest generation’s markings. An undiscovered generation raises EventSchemaNotDiscovered rather than silently borrowing another schema. A discovered provider that cannot be activated fails explicitly with its identity and underlying cause.

Your specs can use the public append-result and event-sequence assertions, or read through EventLog and EventSequence, to verify successful appends and released values. Chronicle’s own harness regressions also inspect the protected in-memory content; that raw-content inspection is internal, not a public consumer assertion API. A plaintext round trip alone does not prove encryption at rest.

Keys are held only in memory and are not shared between scenario instances. This exercises event encryption and release, not production database persistence, read-model sink encryption, key-store durability, or subject erasure. Erasure is unsupported in EventScenario; test it against a running kernel and the intended storage provider.

For generated event types or constraints, create new Defaults(artifacts) with a run-specific IClientArtifactsProvider and pass it to new EventScenario(defaults). This uses the supplied provider for event type discovery, serialization, and automatic constraint discovery. You can also pass an explicit ICanProvideConstraints as the second argument to replace automatic constraint discovery. In the (Defaults, ICanProvideConstraints?) overload, passing null also discovers constraints from the supplied defaults; pass an empty provider (one whose Provide() returns an empty list) to disable constraints. This differs from the older constructors that accept a constraint provider, where null means no constraints. Existing constructors continue using the default shared provider.

To select an identity while keeping automatic discovery, use new EventScenario(eventSequenceId, eventStoreName, namespaceName). For run-specific artifacts, use new EventScenario(defaults, eventSequenceId, eventStoreName, namespaceName). Neither overload takes a constraint provider; the existing explicit-provider overload still treats null as no constraints.

Discovered event migrations run on append. An event appended in an older generation retains its original content and also stores the migrated generations. This does not determine which generation an ordinary read delivers. Migrators use the service provider supplied to Defaults, including their constructor dependencies.

Create a new EventScenario instance per test to keep tests isolated. The in-memory store accumulates state across calls on the same instance. Each instance starts with an empty event log and sequence number zero. Dispose the scenario after the specification to release its in-memory resources.

If your specs supply event types or constraints at runtime, construct new Defaults(artifactsProvider) and pass it to new EventScenario(defaults). This uses that registry for event serialization and constraint discovery instead of the process-wide default. Pass an explicit constraint provider as the second argument when you want to override discovery for this scenario.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Testing.EventSequences;
using Cratis.Specifications;
using Xunit;
public class when_adding_a_book_to_an_author : Specification
{
readonly EventSourceId _authorId = EventSourceId.New();
readonly EventScenario _scenario = new();
AppendResult _result = default!;
Task Establish() =>
_scenario.Given
.ForEventSource(_authorId)
.Events(new TestingScenarioAuthorRegistered("Jane Smith"));
async Task Because() =>
_result = await _scenario.EventLog.Append(_authorId, new TestingScenarioBookAdded("Clean Code"));
[Fact] void should_append_successfully() =>
_result.ShouldBeSuccessful();
[Fact] Task should_have_appended_book_added() =>
_scenario.EventLog.ShouldHaveAppendedEvent<TestingScenarioBookAdded>(new EventSequenceNumber(1));
}

View C# snippet source on GitHub

For information on asserting the result of an append operation, see Assertions.