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()); }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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()); }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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")); }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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()); }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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.
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)); }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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.
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()); }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.For information on asserting the result of an append operation, see Assertions.