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.
Why use EventScenario
Section titled “Why use EventScenario”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.
Installation
Section titled “Installation”EventScenario is in the Cratis.Chronicle.Testing NuGet package:
dotnet add package Cratis.Specifications.XUnitdotnet add package Cratis.Chronicle.TestingBasic usage
Section titled “Basic usage”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(); }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.testing.EventScenarioimport kotlinx.coroutines.runBlockingimport org.junit.jupiter.api.Assertions.assertTrueimport org.junit.jupiter.api.Test
@EventTypedata class TestingScenarioBasicAuthorRegistered(val name: String)
class EventScenarioBasicTests {
@Test fun `appending an event through the scenario succeeds`() = runBlocking { val scenario = EventScenario() val result = scenario.eventLog.append("author-1", TestingScenarioBasicAuthorRegistered("John Doe"))
assertTrue(result.isSuccess) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.AppendResult;import io.cratis.chronicle.java.BlockingEventSequence;import io.cratis.chronicle.testing.EventScenario;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TestingEventsScenarioBasic {
@EventType record AuthorRegistered(String name) { }
@Test void appendingAnEventThroughTheScenarioSucceeds() { var scenario = new EventScenario("testing", "default"); var eventLog = new BlockingEventSequence(scenario.getEventSequence());
AppendResult result = eventLog.append("author-1", new AuthorRegistered("John Doe"));
assertTrue(result.isSuccess()); }}defmodule MyApp.Events.TestingScenarioBasicAuthorRegistered do use Chronicle.Events.EventType, id: "testing-scenario-basic-author-registered"
defstruct [:name]end
defmodule MyApp.Events.TestingScenarioBasicBookAdded do use Chronicle.Events.EventType, id: "testing-scenario-basic-book-added"
defstruct [:title]end
defmodule MyApp.TestingScenarioBasicTest do use Chronicle.Testing.Case, event_types: [MyApp.Events.TestingScenarioBasicAuthorRegistered]
alias Chronicle.EventSequences.EventLog alias MyApp.Events.TestingScenarioBasicAuthorRegistered
test "appending an event succeeds", %{chronicle: client} do {:ok, result} = EventLog.append_with_result( "author-#{System.unique_integer([:positive])}", %TestingScenarioBasicAuthorRegistered{name: "John Doe"}, client: client )
assert result.success? endendimport { field } from '@cratis/fundamentals';import { eventType } from '@cratis/chronicle';import { EventScenario as BasicEventScenario } from '@cratis/chronicle/testing';
class MessageRecorded { @field(String) label: string; constructor(label: string) { this.label = label; }}eventType('MessageRecorded')(MessageRecorded);
const basicScenario = new BasicEventScenario({ artifacts: { eventTypes: [MessageRecorded] }, constraints: 'disabled'});const basicResult = await basicScenario.append('message-1', new MessageRecorded('hello'));const basicHistory = basicScenario.appendedEvents;// basicResult.isSuccess is true; basicHistory contains the serialized event.// Unscoped single-string-property and unique-event-type constraints can be tested// with constraints enabled. Observer completion still requires a kernel-backed test.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.
Pre-seeding state with Given
Section titled “Pre-seeding state with Given”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(); }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.testing.EventScenarioimport kotlinx.coroutines.runBlockingimport org.junit.jupiter.api.Assertions.assertTrueimport org.junit.jupiter.api.Test
@EventTypedata class TestingScenarioGivenAuthorRegistered(val name: String)
@EventTypedata class TestingScenarioGivenBookAdded(val title: String)
class GivenSeedsPreconditionsTests {
@Test fun `preconditions seeded with given are already in the log before the act`() = runBlocking { val scenario = EventScenario() val authorId = "author-1"
scenario.given(authorId, TestingScenarioGivenAuthorRegistered("John Doe"), TestingScenarioGivenBookAdded("Clean Code"))
val result = scenario.eventLog.append(authorId, TestingScenarioGivenBookAdded("The Pragmatic Programmer")) assertTrue(result.isSuccess) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.AppendResult;import io.cratis.chronicle.java.BlockingEventSequence;import io.cratis.chronicle.testing.EventScenario;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TestingEventsScenarioGiven {
@EventType record AuthorRegistered(String name) { }
@EventType record BookAdded(String title) { }
@Test void preconditionsAppendedFirstAreAlreadyInTheLogBeforeTheAct() { var scenario = new EventScenario("testing", "default"); var eventLog = new BlockingEventSequence(scenario.getEventSequence()); var authorId = "author-1";
// The precondition - what was already true before the code under test ran. eventLog.append(authorId, new AuthorRegistered("John Doe")); eventLog.append(authorId, new BookAdded("Clean Code"));
// The act under test. AppendResult result = eventLog.append(authorId, new BookAdded("The Pragmatic Programmer"));
assertTrue(result.isSuccess()); }}defmodule MyApp.Events.TestingScenarioGivenAuthorRegistered do use Chronicle.Events.EventType, id: "testing-scenario-given-author-registered"
defstruct [:name]end
defmodule MyApp.Events.TestingScenarioGivenBookAdded do use Chronicle.Events.EventType, id: "testing-scenario-given-book-added"
defstruct [:title]end
defmodule MyApp.TestingScenarioGivenTest do use Chronicle.Testing.Case, event_types: [MyApp.Events.TestingScenarioGivenAuthorRegistered, MyApp.Events.TestingScenarioGivenBookAdded]
alias Chronicle.EventSequences.EventLog alias MyApp.Events.{TestingScenarioGivenAuthorRegistered, TestingScenarioGivenBookAdded}
test "appending after the given events succeeds", %{chronicle: client} do author_id = "author-#{System.unique_integer([:positive])}"
# Given: events already in the sequence for the event source :ok = EventLog.append_many( author_id, [%TestingScenarioGivenAuthorRegistered{name: "John Doe"}, %TestingScenarioGivenBookAdded{title: "Clean Code"}], client: client )
{:ok, result} = EventLog.append_with_result( author_id, %TestingScenarioGivenBookAdded{title: "The Pragmatic Programmer"}, client: client )
assert result.success? endendimport { field } from '@cratis/fundamentals';import { eventType } from '@cratis/chronicle';import { EventScenario as GivenEventScenario } from '@cratis/chronicle/testing';
class MessageSeeded { @field(String) label: string; constructor(label: string) { this.label = label; }}eventType('MessageSeeded')(MessageSeeded);
const givenScenario = new GivenEventScenario({ artifacts: { eventTypes: [MessageSeeded] }, constraints: 'disabled'});await givenScenario.given.forEventSource('message-1').events( new MessageSeeded('first'), new MessageSeeded('second'));// Given uses sequential single appends, not an atomic batch, but commits nothing if any setup event is rejected.// Setup events enter appendedEvents, not results.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")); }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.testing.EventScenarioimport kotlinx.coroutines.runBlockingimport org.junit.jupiter.api.Test
@EventTypedata class TestingScenarioGivenMultiAuthorRegistered(val name: String)
class GivenMultipleSourcesTests {
@Test fun `given seeds events for different event sources independently`() = runBlocking { val scenario = EventScenario()
scenario.given("author-1", TestingScenarioGivenMultiAuthorRegistered("Jane Smith")) scenario.given("author-2", TestingScenarioGivenMultiAuthorRegistered("John Doe")) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.java.BlockingEventSequence;import io.cratis.chronicle.testing.EventScenario;
import org.junit.jupiter.api.Test;
class TestingEventsScenarioGivenMultipleSources {
@EventType record AuthorRegistered(String name) { }
@Test void preconditionsSeedDifferentEventSourcesIndependently() { var scenario = new EventScenario("testing", "default"); var eventLog = new BlockingEventSequence(scenario.getEventSequence());
eventLog.append("author-1", new AuthorRegistered("Jane Smith")); eventLog.append("author-2", new AuthorRegistered("John Doe")); }}defmodule MyApp.Events.TestingScenarioSourcesAuthorRegistered do use Chronicle.Events.EventType, id: "testing-scenario-sources-author-registered"
defstruct [:name]end
defmodule MyApp.Events.TestingScenarioSourcesBookAdded do use Chronicle.Events.EventType, id: "testing-scenario-sources-book-added"
defstruct [:title]end
defmodule MyApp.TestingScenarioGivenMultipleSourcesTest do use Chronicle.Testing.Case, event_types: [MyApp.Events.TestingScenarioSourcesAuthorRegistered]
import Chronicle.Testing.Assertions
alias Chronicle.EventSequences.EventLog alias MyApp.Events.TestingScenarioSourcesAuthorRegistered
test "events can be given for several event sources", %{chronicle: client} do author1_id = "author-#{System.unique_integer([:positive])}" author2_id = "author-#{System.unique_integer([:positive])}"
:ok = EventLog.append(author1_id, %TestingScenarioSourcesAuthorRegistered{name: "Jane Smith"}, client: client) :ok = EventLog.append(author2_id, %TestingScenarioSourcesAuthorRegistered{name: "John Doe"}, client: client)
assert_appended(client, %TestingScenarioSourcesAuthorRegistered{name: "Jane Smith"}, event_source_id: author1_id) assert_appended(client, %TestingScenarioSourcesAuthorRegistered{name: "John Doe"}, event_source_id: author2_id) endendimport { field } from '@cratis/fundamentals';import { eventType } from '@cratis/chronicle';import { EventScenario as MultiSourceEventScenario } from '@cratis/chronicle/testing';
class MultiSourceMessage { @field(String) label: string; constructor(label: string) { this.label = label; }}eventType('MultiSourceMessage')(MultiSourceMessage);
const multiSourceScenario = new MultiSourceEventScenario({ artifacts: { eventTypes: [MultiSourceMessage] }, constraints: 'disabled'});await multiSourceScenario.given.forEventSource('message-1').events(new MultiSourceMessage('first'));await multiSourceScenario.given.forEventSource('message-2').events(new MultiSourceMessage('second'));// Each setup append is independent; given does not start an atomic batch.Rules:
- Call
Givenbefore 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.
Acting with When
Section titled “Acting with When”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"); }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.testing.EventScenarioimport kotlinx.coroutines.runBlockingimport org.junit.jupiter.api.Assertions.assertTrueimport org.junit.jupiter.api.Test
@EventTypedata class TestingScenarioWhenBuilderAuthorRegistered(val name: String)
/** * Kotlin has no separate `When` builder - the act under test is simply the direct call to * [io.cratis.chronicle.eventSequences.IEventSequence.append], and its return value is the "then". */class EventScenarioActPhaseTests {
@Test fun `the act appends the event under test and returns its result`() = runBlocking { val scenario = EventScenario() val existingAuthorId = "author-1" val newAuthorId = "author-2"
// Given: an author is already registered. scenario.given(existingAuthorId, TestingScenarioWhenBuilderAuthorRegistered("John Doe"))
// When: register a different author under a new event source - the act returns the result. val result = scenario.eventLog.append(newAuthorId, TestingScenarioWhenBuilderAuthorRegistered("John Doe"))
// Then: assert on the returned result. assertTrue(result.isSuccess) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.AppendResult;import io.cratis.chronicle.java.BlockingEventSequence;import io.cratis.chronicle.testing.EventScenario;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** * There is no separate builder for the act phase - the act under test is simply the direct call to * append, and its return value is the "then". */class TestingEventsScenarioWhenBuilder {
@EventType record AuthorRegistered(String name) { }
@Test void theActAppendsTheEventUnderTestAndReturnsItsResult() { var scenario = new EventScenario("testing", "default"); var eventLog = new BlockingEventSequence(scenario.getEventSequence()); var existingAuthorId = "author-1"; var newAuthorId = "author-2";
// Given: an author is already registered. eventLog.append(existingAuthorId, new AuthorRegistered("John Doe"));
// When: register a different author under a new event source - the act returns the result. AppendResult result = eventLog.append(newAuthorId, new AuthorRegistered("John Doe"));
// Then: assert on the returned result. assertTrue(result.isSuccess()); }}defmodule MyApp.Events.TestingScenarioWhenAuthorRegistered do use Chronicle.Events.EventType, id: "testing-scenario-when-author-registered"
unique(:name, name: "TestingScenarioUniqueAuthorName")
defstruct [:name]end
defmodule MyApp.TestingScenarioWhenTest do use Chronicle.Testing.Case, event_types: [MyApp.Events.TestingScenarioWhenAuthorRegistered]
import Chronicle.Testing.Assertions
alias Chronicle.EventSequences.EventLog alias MyApp.Events.TestingScenarioWhenAuthorRegistered
test "registering an existing name under a new event source is rejected", %{chronicle: client} do existing_author_id = "author-#{System.unique_integer([:positive])}" new_author_id = "author-#{System.unique_integer([:positive])}"
# Given: an author with this name is already registered :ok = EventLog.append(existing_author_id, %TestingScenarioWhenAuthorRegistered{name: "John Doe"}, client: client)
# When: attempt to register the same name under a new event source result = EventLog.append(new_author_id, %TestingScenarioWhenAuthorRegistered{name: "John Doe"}, client: client)
# Then: assert on the returned result assert_constraint_violation(result, "TestingScenarioUniqueAuthorName") endendimport { field } from '@cratis/fundamentals';import { eventType } from '@cratis/chronicle';import { EventScenario as WhenEventScenario } from '@cratis/chronicle/testing';
class MessageActed { @field(String) label: string; constructor(label: string) { this.label = label; }}eventType('MessageActed')(MessageActed);
const whenScenario = new WhenEventScenario({ artifacts: { eventTypes: [MessageActed] }, constraints: 'disabled'});const whenResult = await whenScenario.when.forEventSource('message-1').event(new MessageActed('sent'));// whenResult.isSuccess is true; whenScenario.then.results contains the act-phase result.const whenBatch = await whenScenario.when.forEventSource('message-1').events( new MessageActed('first'), new MessageActed('second'));// In TypeScript, plural when.events(...) is one atomic batch with a result per input event;// .NET When.Events instead performs sequential single appends and returns one result.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.
Reading and observing read models
Section titled “Reading and observing read models”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:
- Seed the project with
await scenario.Given.ForEventSource(projectId).Events(new ProjectOpened("Original"));. - Act with
await scenario.When.ForEventSource(projectId).Events(new ProjectRenamed("Renamed"));. - Read its current state with
var project = await scenario.ReadModels.GetInstanceById<ProjectSummary>(projectId.Value);. The projected name is nowRenamed. Useawait 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.
Testing AppendMany
Section titled “Testing AppendMany”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(); }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.testing.EventScenarioimport kotlinx.coroutines.runBlockingimport org.junit.jupiter.api.Assertions.assertTrueimport org.junit.jupiter.api.Test
@EventTypedata class ItemAddedToCart(val itemId: String)
@EventTypedata class ItemQuantityAdjusted(val itemId: String, val quantity: Int)
class EventScenarioAppendManyTests {
@Test fun `appendMany appends a batch of events in one call`() = runBlocking { val scenario = EventScenario() val cartId = "cart-1"
val result = scenario.eventLog.appendMany( cartId, listOf( ItemAddedToCart("item-1"), ItemAddedToCart("item-2"), ItemQuantityAdjusted("item-1", 3) ) )
assertTrue(result.all { it.isSuccess }) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.AppendResult;import io.cratis.chronicle.java.BlockingEventSequence;import io.cratis.chronicle.testing.EventScenario;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TestingEventsScenarioAppendMany {
@EventType record ItemAddedToCart(String itemId) { }
@EventType record ItemQuantityAdjusted(String itemId, int quantity) { }
@Test void appendManyAppendsABatchOfEventsInOneCall() { var scenario = new EventScenario("testing", "default"); var eventLog = new BlockingEventSequence(scenario.getEventSequence()); var cartId = "cart-1";
List<AppendResult> result = eventLog.appendMany(cartId, List.of( new ItemAddedToCart("item-1"), new ItemAddedToCart("item-2"), new ItemQuantityAdjusted("item-1", 3)));
assertTrue(result.stream().allMatch(AppendResult::isSuccess)); }}defmodule MyApp.Events.TestingScenarioManyItemAddedToCart do use Chronicle.Events.EventType, id: "testing-scenario-many-item-added-to-cart"
defstruct [:item_id]end
defmodule MyApp.Events.TestingScenarioManyItemQuantityAdjusted do use Chronicle.Events.EventType, id: "testing-scenario-many-item-quantity-adjusted"
defstruct [:item_id, :quantity]end
defmodule MyApp.TestingScenarioAppendManyTest do use Chronicle.Testing.Case, event_types: [MyApp.Events.TestingScenarioManyItemAddedToCart, MyApp.Events.TestingScenarioManyItemQuantityAdjusted]
alias Chronicle.EventSequences.EventLog alias MyApp.Events.{TestingScenarioManyItemAddedToCart, TestingScenarioManyItemQuantityAdjusted}
test "several events are appended atomically", %{chronicle: client} do cart_id = "cart-#{System.unique_integer([:positive])}"
{:ok, result} = EventLog.append_many_with_result( cart_id, [ %TestingScenarioManyItemAddedToCart{item_id: "item-1"}, %TestingScenarioManyItemAddedToCart{item_id: "item-2"}, %TestingScenarioManyItemQuantityAdjusted{item_id: "item-1", quantity: 3} ], client: client )
assert result.success? assert result.sequence_numbers == [0, 1, 2] endendimport { field } from '@cratis/fundamentals';import { eventType } from '@cratis/chronicle';import { EventScenario as BatchEventScenario } from '@cratis/chronicle/testing';
class BatchMessageRecorded { @field(String) label: string; constructor(label: string) { this.label = label; }}eventType('BatchMessageRecorded')(BatchMessageRecorded);
const batchScenario = new BatchEventScenario({ artifacts: { eventTypes: [BatchMessageRecorded] }, constraints: 'disabled'});const oneSource = await batchScenario.appendMany('message-1', [ new BatchMessageRecorded('first'), new BatchMessageRecorded('second')]);const multipleSources = await batchScenario.appendMany([ { eventSourceId: 'message-1', event: new BatchMessageRecorded('third'), subject: 'message-1' }, { eventSourceId: 'message-2', event: new BatchMessageRecorded('fourth') }]);// Each batch is atomic; oneSource and multipleSources contain one AppendResult per event.// Unscoped single-string-property and unique-event-type constraints are fixture-backed.// Scoped, composite, replacement and removal behavior still needs a kernel-backed test.Closing and reopening streams
Section titled “Closing and reopening streams”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(); }}defmodule MyApp.Events.TestingScenarioBooksReopened do use Chronicle.Events.EventType, id: "testing-scenario-books-reopened"
defstruct [:period]end
defmodule MyApp.Events.TestingScenarioBooksClosed do use Chronicle.Events.EventType, id: "testing-scenario-books-closed"
defstruct [:period]
# The stream id comes from the period. The scope is the event source and that stream id. closes_stream event_stream_id_from: :period, dimensions: [:event_source_id], reopened_by: [MyApp.Events.TestingScenarioBooksReopened]end
defmodule MyApp.Events.TestingScenarioEntryPosted do use Chronicle.Events.EventType, id: "testing-scenario-entry-posted"
defstruct [:description]end
# The in-memory kernel does not model closing events, so this spec needs a real kernel.defmodule MyApp.TestingScenarioClosingEventTest do use ExUnit.Case
@moduletag :integration
import Chronicle.Testing.Assertions
alias Chronicle.EventSequences.EventLog alias MyApp.Events.{TestingScenarioBooksClosed, TestingScenarioEntryPosted}
setup do name = :"closing_event_#{System.unique_integer([:positive])}"
{:ok, client} = Chronicle.Testing.Integration.setup_isolated_client( [ event_types: [ MyApp.Events.TestingScenarioBooksClosed, MyApp.Events.TestingScenarioBooksReopened, MyApp.Events.TestingScenarioEntryPosted ] ], name )
%{chronicle: client} end
test "a closing event closes the stream it names", %{chronicle: client} do :ok = EventLog.append("ledger-1", %TestingScenarioBooksClosed{period: "2026-04"}, client: client)
# Covered: same event source, stream id taken from the closing event's period. late = EventLog.append("ledger-1", %TestingScenarioEntryPosted{description: "Late invoice"}, client: client, event_stream_type: "ledger", event_stream_id: "2026-04" )
assert_constraint_violation(late)
# Not covered: another event source, and the next period. assert :ok = EventLog.append("ledger-2", %TestingScenarioEntryPosted{description: "Open"}, client: client, event_stream_type: "ledger", event_stream_id: "2026-04" )
assert :ok = EventLog.append("ledger-1", %TestingScenarioEntryPosted{description: "Next"}, client: client, event_stream_type: "ledger", event_stream_id: "2026-05" ) endendSeed 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(); }}defmodule MyApp.Events.TestingScenarioReopeningBooksReopened do use Chronicle.Events.EventType, id: "testing-scenario-reopening-books-reopened"
defstruct [:period]end
defmodule MyApp.Events.TestingScenarioReopeningBooksClosed do use Chronicle.Events.EventType, id: "testing-scenario-reopening-books-closed"
defstruct [:period]
closes_stream event_stream_id_from: :period, dimensions: [:event_source_id], reopened_by: [MyApp.Events.TestingScenarioReopeningBooksReopened]end
defmodule MyApp.Events.TestingScenarioReopeningEntryPosted do use Chronicle.Events.EventType, id: "testing-scenario-reopening-entry-posted"
defstruct [:description]end
# The in-memory kernel does not model closing events, so this spec needs a real kernel.defmodule MyApp.TestingScenarioReopeningEventTest do use ExUnit.Case
@moduletag :integration
alias Chronicle.EventSequences.EventLog
alias MyApp.Events.{ TestingScenarioReopeningBooksClosed, TestingScenarioReopeningBooksReopened, TestingScenarioReopeningEntryPosted }
setup do name = :"reopening_event_#{System.unique_integer([:positive])}"
{:ok, client} = Chronicle.Testing.Integration.setup_isolated_client( [ event_types: [ MyApp.Events.TestingScenarioReopeningBooksClosed, MyApp.Events.TestingScenarioReopeningBooksReopened, MyApp.Events.TestingScenarioReopeningEntryPosted ] ], name )
%{chronicle: client} end
test "the reopening event lifts the closure", %{chronicle: client} do :ok = EventLog.append("ledger-1", %TestingScenarioReopeningBooksClosed{period: "2026-04"}, client: client)
# The reopening fact is accepted inside the scope it reopens. assert :ok = EventLog.append( "ledger-1", %TestingScenarioReopeningBooksReopened{period: "2026-04"}, client: client )
assert :ok = EventLog.append("ledger-1", %TestingScenarioReopeningEntryPosted{description: "Correction"}, client: client, event_stream_type: "ledger", event_stream_id: "2026-04" ) endendManual 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}"); }}defmodule MyApp.Events.TestingScenarioManualCloseReportEdited do use Chronicle.Events.EventType, id: "testing-scenario-manual-close-report-edited"
defstruct [:text]end
# The in-memory kernel does not model closed streams, so this spec needs a real kernel.defmodule MyApp.TestingScenarioManualCloseTest do use ExUnit.Case
@moduletag :integration
import Chronicle.Testing.Assertions
alias Chronicle.EventSequences.{EventLog, Streams} alias MyApp.Events.TestingScenarioManualCloseReportEdited
setup do name = :"manual_close_#{System.unique_integer([:positive])}"
{:ok, client} = Chronicle.Testing.Integration.setup_isolated_client( [event_types: [MyApp.Events.TestingScenarioManualCloseReportEdited]], name )
%{chronicle: client} end
test "a closed stream refuses later events", %{chronicle: client} do {:ok, _sequence_number} = Streams.complete_scope(client: client, event_source_id: "report-1", event_stream_id: "2026-04")
edit = EventLog.append("report-1", %TestingScenarioManualCloseReportEdited{text: "Late edit"}, client: client, event_stream_type: "monthly-report", event_stream_id: "2026-04" )
assert_constraint_violation(edit)
{:ok, closed_streams} = Streams.closed(client: client) assert length(closed_streams) == 1 endendWithin 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.
PII compliance
Section titled “PII compliance”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.
Per-run artifacts and constraints
Section titled “Per-run artifacts and constraints”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.
Event generations
Section titled “Event generations”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.
Isolation
Section titled “Isolation”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.
Example: full test
Section titled “Example: full test”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));}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.testing.EventScenarioimport io.cratis.chronicle.testing.shouldHaveAppendedimport kotlinx.coroutines.runBlockingimport org.junit.jupiter.api.Assertions.assertTrueimport org.junit.jupiter.api.Test
@EventTypedata class TestingScenarioFullExampleAuthorRegistered(val name: String)
@EventTypedata class TestingScenarioFullExampleBookAdded(val title: String)
class WhenAddingABookToAnAuthorTests {
@Test fun `the book is appended after the author is registered`() = runBlocking { val scenario = EventScenario() val authorId = "author-1"
scenario.given(authorId, TestingScenarioFullExampleAuthorRegistered("Jane Smith"))
val result = scenario.eventLog.append(authorId, TestingScenarioFullExampleBookAdded("Clean Code"))
assertTrue(result.isSuccess) scenario.shouldHaveAppended<TestingScenarioFullExampleBookAdded>(authorId) { it.title == "Clean Code" } }}import com.google.gson.Gson;
import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.AppendResult;import io.cratis.chronicle.java.BlockingEventSequence;import io.cratis.chronicle.testing.EventScenario;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertTrue;
class TestingEventsScenarioFullExample {
@EventType record AuthorRegistered(String name) { }
@EventType record BookAdded(String title) { }
@Test void theBookIsAppendedAfterTheAuthorIsRegistered() { var scenario = new EventScenario("testing", "default"); var eventLog = new BlockingEventSequence(scenario.getEventSequence()); var authorId = "author-1"; var gson = new Gson();
eventLog.append(authorId, new AuthorRegistered("Jane Smith"));
AppendResult result = eventLog.append(authorId, new BookAdded("Clean Code"));
assertTrue(result.isSuccess());
var book = gson.fromJson( eventLog.getForEventSourceIdAndEventTypes(authorId, BookAdded.class).get(0).getContent(), BookAdded.class); assertEquals("Clean Code", book.title()); }}defmodule MyApp.Events.TestingScenarioFullAuthorRegistered do use Chronicle.Events.EventType, id: "testing-scenario-full-author-registered"
defstruct [:name]end
defmodule MyApp.Events.TestingScenarioFullBookAdded do use Chronicle.Events.EventType, id: "testing-scenario-full-book-added"
defstruct [:title]end
defmodule MyApp.TestingScenarioFullExampleTest do use Chronicle.Testing.Case, event_types: [MyApp.Events.TestingScenarioFullAuthorRegistered, MyApp.Events.TestingScenarioFullBookAdded]
import Chronicle.Testing.Assertions
alias Chronicle.EventSequences.EventLog alias MyApp.Events.{TestingScenarioFullAuthorRegistered, TestingScenarioFullBookAdded}
setup %{chronicle: client} do author_id = "author-#{System.unique_integer([:positive])}"
# Given: an author who is already registered :ok = EventLog.append(author_id, %TestingScenarioFullAuthorRegistered{name: "Jane Smith"}, client: client)
%{author_id: author_id} end
describe "when adding a book to an author" do setup %{chronicle: client, author_id: author_id} do {:ok, result} = EventLog.append_with_result(author_id, %TestingScenarioFullBookAdded{title: "Clean Code"}, client: client)
%{result: result} end
test "appends successfully", %{result: result} do assert result.success? end
test "appends the book added event after the author", %{chronicle: client, author_id: author_id, result: result} do assert result.sequence_number == 1 assert_appended(client, %TestingScenarioFullBookAdded{title: "Clean Code"}, event_source_id: author_id) end endendimport { field } from '@cratis/fundamentals';import { eventType } from '@cratis/chronicle';import { EventScenario as CompleteEventScenario } from '@cratis/chronicle/testing';
class CompleteMessageRecorded { @field(String) label: string; constructor(label: string) { this.label = label; }}eventType('CompleteMessageRecorded')(CompleteMessageRecorded);
const completeScenario = new CompleteEventScenario({ artifacts: { eventTypes: [CompleteMessageRecorded] }, constraints: 'disabled'});await completeScenario.given.forEventSource('message-1').events(new CompleteMessageRecorded('before'));const completeResults = await completeScenario.when.forEventSource('message-1').events( new CompleteMessageRecorded('first'), new CompleteMessageRecorded('second'));const completeHistory = completeScenario.appendedEvents;// Setup is sequential; the two action events are one atomic batch.// completeResults has two items; completeHistory has three accepted events.For information on asserting the result of an append operation, see Assertions.