Testing
Chronicle provides in-process scenarios for testing event appends and read models without a running kernel. The .NET and TypeScript testing libraries also cover reactor side effects, and .NET offers integration fixtures. Cratis.Chronicle.XUnit.Integration exercises the full client and kernel stack against MongoDB via Testcontainers when you need to prove storage, network, or full-stack behavior.
In .NET applications, Chronicle tests usually sit inside Cratis Specifications. That makes the event-sourced shape explicit: Establish() or a scenario’s Given builder seeds facts that already happened, Because() appends or provides the event under test, and each [Fact] asserts the new facts, read model state, or side effect.
Choosing the right test boundary
Section titled “Choosing the right test boundary”Start with the in-process scenarios your client supports. They test the slice without infrastructure; use a kernel-backed test when you need to prove behavior the client’s scenario does not cover. The table below names the .NET scenarios and integration fixtures:
| What to prove | Start with | What it does not prove |
|---|---|---|
| Event appends, constraint violations, sequencing | EventScenario | Network serialization, kernel connection, storage write |
| Read model projection or reducer logic | ReadModelScenario<T> | Sink writes, MongoDB queries, change stream subscription |
| Reactor side effects and event ordering | ReactorScenario<T> | Network observer registration, kernel event dispatch, redelivery — each event is handed to the reactor once, so a repeated delivery (and the idempotence it requires) needs a spec that delivers the event twice |
| Actual storage writes, kernel connectivity, change streams | ChronicleInProcessFixture / ChronicleOutOfProcessFixture | Client isolation—the kernel is shared across tests |
The .NET integration fixtures available in Cratis.Chronicle.XUnit.Integration are:
ChronicleInProcessFixture— an in-process Chronicle kernel with MongoDB via Testcontainers.ChronicleOutOfProcessFixture— a Chronicle kernel in a separate container, also using Testcontainers.ChronicleClientFixture— a base for integration tests that only need a Chronicle client reference.ChronicleOrleansFixture— an Orleans-based Chronicle kernel for testing Chronicle’s own kernel internals.
Only the in-process and out-of-process fixtures are documented here; the others are for framework or advanced scenarios.
.NET packages
Section titled “.NET packages”<PackageReference Include="Cratis.Specifications.XUnit" /><PackageReference Include="Cratis.Chronicle.Testing" />Cratis.Chronicle.Testing brings in Cratis.Arc and Cratis.Arc.Core, including the ASP.NET Core shared framework. You do not need to reference Arc separately for Chronicle-only tests.
Matching versions
Section titled “Matching versions”Chronicle and Arc have independent release numbers. Match Cratis.Chronicle.Testing to your Chronicle client release; its NuGet dependency list identifies the Arc version that release targets. For example, in the Chronicle.Testing 19.26.x version line, 19.26.0 targets Arc 22.30.0. Check each release’s dependency list rather than assuming all releases in that line target the same Arc version. A Cratis meta-package release can target an earlier Chronicle client, so its version alone does not identify a matching Chronicle.Testing package.
To use a newer compatible Arc 22.x release with an older Chronicle.Testing release, pin both Cratis.Arc and Cratis.Arc.Core to the same version in Directory.Packages.props and reference them directly in the test project. Alternatively, enable CentralPackageTransitivePinningEnabled in that file to apply the central pins to transitive dependencies. Align any other Arc runtime packages your application uses with that version. Do not downgrade below Chronicle.Testing’s declared dependencies; verify your scenarios after overriding them.
Use Cratis.Testing instead of Cratis.Chronicle.Testing when the same spec also tests Arc commands.
Application command execution scopes, including Chronicle transactions, belong to the outer Arc command pipeline. The in-process scenario helpers do not discover or invoke your application’s ICommandExecutionScope implementations when processing an event append. Your application scopes still run around the application command; you do not need to register their dependencies with the scenario harness.
Explicit artifacts and lifetime
Section titled “Explicit artifacts and lifetime”The .NET scenarios accept per-run Defaults with ChronicleOptions.ExplicitArtifacts. The same nominated event types, reducers, reactors, constraints and migrations feed actual scenario processing. A plain ReactorScenario<T> requires explicit reactor nomination; it does not discover arbitrary classes by shape.
using Cratis.Chronicle;using Cratis.Chronicle.Events;using Cratis.Chronicle.Testing;using Cratis.Chronicle.Testing.EventSequences;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Chronicle.Testing.Reactors;using Cratis.Specifications;
namespace ExplicitScenarioExample{ public record Credited(int Amount); public record CreditNoted(int Amount); public record Balance(int Amount);
public class BalanceReducer { public Balance Reduce(Credited @event, Balance? current) => new((current?.Amount ?? 0) + @event.Amount); }
public class Notifier { public CreditNoted On(Credited @event) => new(@event.Amount); }
public static class Scenarios { public static async Task Verify() { var options = new ChronicleOptions(); options.ExplicitArtifacts.RegisterEventType<Credited>().RegisterEventType<CreditNoted>() .RegisterReducer<BalanceReducer>().RegisterReactor<Notifier>(); var defaults = new Defaults(new InMemoryClientArtifactsProvider(), null, options); var source = EventSourceId.New();
var readModel = new ReadModelScenario<Balance>(null, defaults); await readModel.Given.ForEventSource(source).Events(new Credited(3), new Credited(4)); readModel.Instance!.Amount.ShouldEqual(7);
using var reactor = new ReactorScenario<Notifier>(defaults); await reactor.Given.ForEventSource(source).Events(new Credited(3)); reactor.ShouldHaveProduced<CreditNoted>(_ => _.Amount == 3);
using var events = new EventScenario(defaults); (await events.EventLog.Append(source, new Credited(5))).ShouldBeSuccessful(); } }}Create independent defaults/options for independent runs. Each scenario store captures its original recipes; changing shared options afterward does not rewrite an existing store. Do not mutate Defaults.Instance or the default artifact provider to configure a run.
XUnit integration options are host-lifetime, matching ChronicleOptions: configure explicit artifacts before the fixture host/client/store is created. Reuse of that host keeps its first snapshot, even after rediscovery. Separate fixture hosts have isolated snapshots. The existing sharing boundary is the closed ChronicleClientFixture<TChronicleFixture> family; derived scenarios in the same family share its host. A fixture that overrides ChronicleFixture.UsesPerContextMongoDBHost to return true starts a fresh host for each test context, so each context captures its own snapshot. AutoDiscoverArtifacts = false disables assembly discovery, not explicit nominations or automatic client registration. It is different from AutoDiscoverAndRegister = false, which requires manual discovery and registration.
See the .NET explicit registration reference for peer conflicts, migration binding and custom-provider limits.
.NET testing store identity
Section titled “.NET testing store identity”When code under test distinguishes stores by identity, construct EventStoreForTesting(serviceProvider, artifacts, options, time, eventStore, @namespace, capabilities: null) or Defaults(artifacts, serviceProvider, options, time, eventStore, @namespace). The eventStore and @namespace arguments are EventStoreName and EventStoreNamespaceName. EventStoreForTesting requires a ScenarioTime; Defaults accepts a null clock.
The resulting store’s Name, Namespace, event log and unit-of-work operations use that identity. Pass those defaults to ReadModelScenario, ReactorScenario or OccurrenceScenario to give the scenario’s store the same identity. A reactor scenario supplied with an explicit event store uses that store’s identity instead, including in delivered event contexts. EventScenario keeps its own identity parameters and does not inherit the defaults’ store identity.
Each store instance has isolated in-memory storage, even when identities match. Existing EventStoreForTesting and Defaults overloads still create their store as testing in namespace default.
Topics
Section titled “Topics”| Guide | Description |
|---|---|
| Events | Test event appends and constraint behavior in-process using EventScenario |
| Read Models | Test projections and reducers in-process using ReadModelScenario<TReadModel> |
| Reactors | Test reactor side-effects in-process using ReactorScenario<TReactor> |
| Occurrences and receipts | Drive leased trigger deliveries, retry time and commit-then-drop-reply recovery |
| Event append collection | Integration testing: capture every appended event in a scoped collector with IEventAppendCollection |
A specification-shaped example
Section titled “A specification-shaped example”using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.Testing.ReadModels;using Cratis.Specifications;using Xunit;
[EventType]public record AuthorRegistered(string Name);
[FromEvent<AuthorRegistered>]public record Author([Key] Guid Id, string Name);
public class when_projecting_a_registered_author : Specification{ readonly EventSourceId _authorId = EventSourceId.New(); readonly ReadModelScenario<Author> _scenario = new();
Task Because() => _scenario.Given .ForEventSource(_authorId) .Events(new AuthorRegistered("Jane Austen"));
[Fact] void should_set_the_author_name() => _scenario.Instance!.Name.ShouldEqual("Jane Austen");}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reducerimport io.cratis.chronicle.readModels.ReadModelimport io.cratis.chronicle.testing.ReadModelScenarioimport kotlinx.coroutines.runBlockingimport org.junit.jupiter.api.Assertions.assertEqualsimport org.junit.jupiter.api.Test
@EventTypedata class AuthorRegistered(val name: String)
@ReadModeldata class Author(val name: String = "")
@Reducerclass AuthorReducer { fun registered(event: AuthorRegistered) = Author(event.name)}
class WhenProjectingARegisteredAuthorTests {
@Test fun `the author read model carries the registered name`() = runBlocking { val scenario = ReadModelScenario<Author>(AuthorReducer())
val author = scenario.fold("author-1", AuthorRegistered("Jane Austen"))
assertEquals("Jane Austen", author!!.name) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reducer;import io.cratis.chronicle.readModels.ReadModel;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class TestingExampleSpec {
@EventType record AuthorRegistered(String name) { }
@ReadModel record Author(String name) { }
@Reducer static class AuthorReducer { Author registered(AuthorRegistered event) { return new Author(event.name()); } }
@Test void theAuthorReadModelCarriesTheRegisteredName() { var reducer = new AuthorReducer();
var author = reducer.registered(new AuthorRegistered("Jane Austen"));
assertEquals("Jane Austen", author.name()); }}defmodule MyApp.Events.TestingIndexAuthorRegistered do use Chronicle.Events.EventType, id: "testing-index-author-registered"
defstruct [:name]end
defmodule MyApp.ReadModels.TestingIndexAuthor do use Chronicle.ReadModels.ReadModel
alias MyApp.Events.TestingIndexAuthorRegistered
defstruct id: nil, name: nil
from TestingIndexAuthorRegistered, set: [id: :event_source_id, name: :name]end
defmodule MyApp.TestingIndexExampleSpecTest do use ExUnit.Case, async: true
alias MyApp.Events.TestingIndexAuthorRegistered alias MyApp.ReadModels.TestingIndexAuthor
test "projecting a registered author sets the author name" do author_id = "author-#{System.unique_integer([:positive])}"
{:ok, author} = Chronicle.Testing.Projections.apply_event( TestingIndexAuthor, nil, %TestingIndexAuthorRegistered{name: "Jane Austen"}, author_id )
assert author.name == "Jane Austen" endendimport { field } from '@cratis/fundamentals';import { eventType, fromEvent } from '@cratis/chronicle';import { ReadModelScenario } from '@cratis/chronicle/testing';
@eventType()class AuthorRegistered { @field(String) name: string; constructor(name: string) { this.name = name; }}
@fromEvent(AuthorRegistered)class Author { @field(String) id = ''; @field(String) name = '';}
const authorScenario = new ReadModelScenario(Author);authorScenario.given.forEventSource('author-1').events(new AuthorRegistered('Jane Austen'));const projectedAuthor = await authorScenario.instance;if (projectedAuthor?.name !== 'Jane Austen') throw new Error('Expected the registered author name');The read-model logic runs in-process — a projection or reducer, depending on the client — and the assertion stays close to the domain: given the AuthorRegistered fact, the Author read model should contain the name the screen needs.
What the harness stands in for
Section titled “What the harness stands in for”The .NET in-process scenario helpers run the real projection and reducer engines, constraint evaluation, and reactor dispatch on Chronicle’s production in-memory storage. Each scenario owns an isolated store shared by its event log, named sequences, and in-process services. They bypass gRPC and the observer lifecycle, so they remain fast without proving network delivery or persistent storage behavior.
When a .NET test needs to prove behavior across that boundary — that a projection’s sink writes actually landed in MongoDB, that a reactor registered its observer subscription with a running kernel, that change streams fired and advanced the checkpoint — reach for the integration fixtures and IEventAppendCollection instead. Those exercise the full client-to-kernel-to-storage stack.
For a worked example of IEventAppendCollection and the integration fixtures, see Event append collection. For the full slice testing story — how /chronicle/scenarios/test-a-slice/ fits with the in-process helpers and the integration boundary — see Test a slice.
For the bigger testing model across Specifications, Arc, Chronicle, and full stack slices, see Testing with Cratis.