Testing with Cratis
Testing
Turn the model into executable examples
Cratis applications already describe behavior as a timeline: a screen sends a command, the command records an event, projections build read models, and reactors act on facts. The testing story follows the same shape. You write a small specification for the behavior, seed the facts that already happened, perform the command or event, and assert the new facts, read models, or side effects.
Test packages by backend
Section titled “Test packages by backend”Each backend tests a command through the real Arc command pipeline and a read model without a Chronicle server. The packages differ:
| Backend | Command specs | Read-model specs |
|---|---|---|
| C# | Cratis.Arc.Testing (CommandScenario<TCommand>), with Cratis.Arc.Chronicle.Testing for the events a command appends | Cratis.Chronicle.Testing (ReadModelScenario<TReadModel>) |
| Kotlin | io.cratis:arc-testing (CommandScenario), with io.cratis:arc-chronicle-spring-boot-starter for the events a command appends | io.cratis:chronicle-testing (ReadModelScenario) |
| Java | io.cratis:arc-testing, driven through BlockingCommandScenario, with the same Chronicle starter | The examples call the reducer directly |
| TypeScript (preview) | @cratis/arc.testing (CommandScenario), with @cratis/arc.chronicle/testing (ChronicleCommandScenario) for the events a command appends | @cratis/chronicle/testing (ReadModelScenario) |
The Arc for Kotlin and Java testing guide and the Arc for TypeScript testing guide cover the JVM and TypeScript harnesses in depth. The rest of this section lists the .NET packages.
The .NET packages
Section titled “The .NET packages”For .NET, the packages are layered the same way the runtime is layered:
| Package | Use it for |
|---|---|
Cratis.Specifications.XUnit | BDD-style specifications on top of xUnit: Establish() for given, Because() for when, [Fact] methods for then, plus Should* assertions and Catch.Exception. |
Cratis.Specifications.NUnit | The same specification style for NUnit projects. |
Cratis.Arc.Testing | CommandScenario<TCommand> for running Arc commands through the real command pipeline without HTTP. |
Cratis.Chronicle.Testing | EventScenario, ReadModelScenario<TReadModel>, and ReactorScenario<TReactor> for in-process Chronicle tests without a server or database. |
Cratis.Arc.Chronicle.Testing | Automatically extends CommandScenario<TCommand> with an in-memory Chronicle event log when the package is referenced. |
Cratis.Testing | Convenience package for Arc + Chronicle application tests. Use it with Cratis.Specifications.XUnit. |
For most .NET Cratis stack slices, reference:
<PackageReference Include="Cratis.Specifications.XUnit" /><PackageReference Include="Cratis.Testing" />Use Cratis.Chronicle.Testing directly for Chronicle-only libraries, and Cratis.Arc.Testing directly when a slice deliberately has no event-sourced write side.
Why BDD fits event sourcing
Section titled “Why BDD fits event sourcing”Given/when/then maps one to one onto event sourcing: the given is the facts that already happened, the when is the command or event under test, and the then is the facts, read models, or side effects that result. The .NET specification APIs line up like this:
| Specification word | Event-sourced meaning | Cratis test API |
|---|---|---|
| Given | Facts that already happened, or read model state that already exists | EventScenario.Given, ReadModelScenario.Given, reusable given base contexts |
| When | The command, append, projection input, or reactor trigger under test | CommandScenario.Execute, EventLog.Append, ReactorScenario.Given.ForEventSource(...).Events(...) |
| Then | The observable result: appended events, command result, projected read model, or side effect | [Fact] methods with ShouldBeSuccessful, ShouldHaveAppendedEvent, ShouldEqual, or mock assertions |
That makes specs readable in the same language as the event model. A domain expert can read “given an author already exists, when registering the same name, then it should not append another event” and understand the rule without knowing the implementation.
The event model becomes the spec
Section titled “The event model becomes the spec”Start with one column from an event model:
Read it as a testable behavior:
| Given | When | Then |
|---|---|---|
| no author exists for this id | RegisterAuthor is executed | the command succeeds |
| no author exists for this id | RegisterAuthor is executed | AuthorRegistered is appended for the author id |
AuthorRegistered exists | the projection handles it | the Author read model contains the name the screen needs |
The slice
Section titled “The slice”The command’s handler returns the event, and Arc appends it to the event source the command identifies. The slice stays small: each snippet holds only the RegisterAuthor command and the AuthorRegistered event. The Author read model from the event model comes in with the spec under Spec the read side.
using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;
namespace Library.Authors;
[Command]public record RegisterAuthor(EventSourceId AuthorId, string Name){ public AuthorRegistered Handle() => new(Name);}
[EventType]public record AuthorRegistered(string Name);import io.cratis.arc.artifacts.Command;import io.cratis.arc.artifacts.CommandKey;import io.cratis.chronicle.events.EventType;
@EventTypepublic record AuthorRegistered(String name) { }
@Commandpublic record RegisterAuthor(@CommandKey String authorId, String name) { public AuthorRegistered handle() { return new AuthorRegistered(name); }}package library.authors
import io.cratis.arc.artifacts.Commandimport io.cratis.arc.artifacts.CommandKeyimport io.cratis.chronicle.events.EventType
@EventTypedata class AuthorRegistered(val name: String)
@Commanddata class RegisterAuthor(@CommandKey val authorId: String, val name: String) { fun handle(): AuthorRegistered = AuthorRegistered(name)}import { field } from '@cratis/fundamentals';import { command, key } from '@cratis/arc.core';import { eventType } from '@cratis/chronicle/events';
@eventType()export class AuthorRegistered { @field(String) name: string;
constructor(name = '') { this.name = name; }}
@command()export class RegisterAuthor { @field(String) @key() authorId = ''; @field(String) name = '';
handle(): AuthorRegistered { return new AuthorRegistered(this.name); }}Spec the command
Section titled “Spec the command”The command spec covers the first two rows: the command succeeds, and AuthorRegistered is recorded for the author id.
using Cratis.Arc.Chronicle.Testing.Commands;using Cratis.Arc.Commands;using Cratis.Arc.Testing.Commands;using Cratis.Chronicle.Events;using Cratis.Specifications;using Xunit;
namespace Library.Authors.for_RegisterAuthor;
public class when_registering_a_new_author : Specification{ readonly EventSourceId _authorId = EventSourceId.New(); readonly CommandScenario<RegisterAuthor> _scenario = new(); CommandResult _result = default!;
async Task Because() => _result = await _scenario.Execute(new RegisterAuthor(_authorId, "Jane Austen"));
[Fact] void should_accept_the_command() => _result.ShouldBeSuccessful();
[Fact] Task should_record_the_fact() => _scenario.ShouldHaveAppendedEvent<RegisterAuthor, AuthorRegistered>( _authorId, e => e.Name == "Jane Austen");
void Destroy() => _scenario.Dispose();}import io.cratis.arc.chronicle.ChronicleCommandScenario;import io.cratis.arc.chronicle.ChronicleCommandScenarios;import io.cratis.arc.generated.LibraryArcArtifactModule;import io.cratis.arc.testing.CommandScenario;import io.cratis.arc.testing.java.BlockingCommandScenario;import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class WhenRegisteringANewAuthor { @Test void recordsTheFact() { var configured = new CommandScenario<>(new LibraryArcArtifactModule(), RegisterAuthor.class); ChronicleCommandScenario chronicle = ChronicleCommandScenarios.chronicle(configured);
try (var scenario = new BlockingCommandScenario<>(configured)) { scenario.execute(new RegisterAuthor("author-1", "Jane Austen")).shouldSucceed(); }
var registered = chronicle.shouldHaveAppendedEvent("author-1", AuthorRegistered.class); assertEquals("Jane Austen", registered.name()); }}package library.authors
import io.cratis.arc.chronicle.chronicleimport io.cratis.arc.generated.LibraryArcArtifactModuleimport io.cratis.arc.testing.CommandScenarioimport kotlin.test.assertEqualsimport kotlinx.coroutines.runBlockingimport org.junit.jupiter.api.Test
class WhenRegisteringANewAuthor { @Test fun `records the fact`() = runBlocking { val scenario = CommandScenario(LibraryArcArtifactModule(), RegisterAuthor::class.java)
val result = scenario.execute(RegisterAuthor("author-1", "Jane Austen"))
result.shouldSucceed() val registered = scenario.chronicle().shouldHaveAppendedEvent<AuthorRegistered>("author-1") assertEquals("Jane Austen", registered.name) }}import { ChronicleCommandScenario } from '@cratis/arc.chronicle/testing';import { afterEach, beforeEach, describe, it } from 'vitest';import { AuthorRegistered, RegisterAuthor } from './RegisterAuthor.js';
describe('when registering a new author', () => { let scenario: ChronicleCommandScenario<RegisterAuthor>; let result: Awaited<ReturnType<typeof scenario.execute>>;
beforeEach(async () => { scenario = ChronicleCommandScenario.for(RegisterAuthor, AuthorRegistered); result = await scenario.execute({ authorId: 'author-1', name: 'Jane Austen' }); });
afterEach(async () => { await scenario.dispose(); });
it('should accept the command', () => { result.shouldBeSuccessful(); });
it('should record the fact', () => { result.shouldHaveAppendedEvent(AuthorRegistered, 'author-1', event => event.name === 'Jane Austen'); });});There is no HTTP server in that spec and no Chronicle server to start. Arc runs the command through the real command pipeline, and each backend’s Chronicle test support captures the events the command appends in memory. In Kotlin and Java, shouldHaveAppendedEvent expects exactly one matching event; C# (ShouldHaveAppendedEvent) and TypeScript accept one or more. TypeScript’s ChronicleCommandScenario does not enforce constraints; the Arc for TypeScript testing guide lists what it does not cover.
Spec the read side
Section titled “Spec the read side”The read-side spec covers the last row: given AuthorRegistered, the Author read model contains the name the screen needs. C#, Kotlin, and TypeScript run the read model in process with a read-model scenario; Java calls its reducer directly. Elixir does not support this test yet. Each example declares AuthorRegistered again so it compiles on its own (in your application, reuse the event from the slice) and introduces the Author read model. These tabs are the Chronicle client set, which also includes Elixir. They are separate from the Arc backend tabs above, so picking a backend there does not switch them.
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');In C#, one spec can cover both sides: pass the contents of the command scenario’s AppendedEvents to ReadModelScenario<TReadModel>.Given.ForEventSource(...).Events(...), and the last assertion checks the same state the React query reads.
How to test Arc
Section titled “How to test Arc”Use Arc tests for command behavior: validation, authorization, injected services, and whether the command succeeds. These examples register a service before executing a command that calls it through the Arc pipeline:
using System.Threading.Tasks;using Cratis.Arc.Commands;using Cratis.Arc.Testing.Commands;using Cratis.Specifications;using Library.Authors;using Microsoft.Extensions.DependencyInjection;using NSubstitute;using Xunit;
namespace Library.Specs;
public class when_recording_an_author : Specification{ readonly CommandScenario<RecordAuthor> _scenario = new(); readonly AuthorId _id = AuthorId.New(); readonly AuthorName _name = new("Ada Lovelace"); IAuthorRegistration _registration = null!; CommandResult _result = null!;
void Establish() { _registration = Substitute.For<IAuthorRegistration>(); _registration.Register(_id, _name).Returns(Task.CompletedTask); _scenario.Services.AddSingleton(_registration); }
async Task Because() => _result = await _scenario.Execute(new RecordAuthor(_id, _name));
[Fact] void should_succeed() => _result.ShouldBeSuccessful(); [Fact] async Task should_register_the_author() => await _registration.Received(1).Register(_id, _name);
void Destroy() => _scenario.Dispose();}import io.cratis.arc.generated.LibraryArcArtifactModule;import io.cratis.arc.testing.CommandScenario;import io.cratis.arc.testing.java.BlockingCommandScenario;import java.util.ArrayList;import java.util.List;import java.util.UUID;import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class WhenRecordingAnAuthor { @Test void recordsTheAuthor() { var id = new AuthorId(UUID.randomUUID()); var name = new AuthorName("Ada Lovelace"); var recorded = new ArrayList<AuthorId>(); AuthorRegistration registration = (registeredId, registeredName) -> recorded.add(registeredId);
var scenario = new CommandScenario<>(new LibraryArcArtifactModule(), RecordAuthor.class) .addService(AuthorRegistration.class, registration);
// BlockingCommandScenario owns a bounded dispatcher, so a JUnit test needs no coroutine. try (var blocking = new BlockingCommandScenario<>(scenario)) { blocking.execute(new RecordAuthor(id, name)).shouldSucceed(); }
assertEquals(List.of(id), recorded); }}package library.specs
import io.cratis.arc.generated.LibraryArcArtifactModuleimport io.cratis.arc.testing.CommandScenarioimport kotlinx.coroutines.runBlockingimport library.authors.AuthorIdimport library.authors.AuthorNameimport library.authors.AuthorRegistrationimport library.authors.RecordAuthorimport java.util.UUIDimport kotlin.test.assertEqualsimport org.junit.jupiter.api.Test
class WhenRecordingAnAuthor { @Test fun `records the author`() = runBlocking { val id = AuthorId(UUID.randomUUID()) val name = AuthorName("Ada Lovelace") val recorded = mutableListOf<Pair<AuthorId, AuthorName>>() val registration = object : AuthorRegistration { override suspend fun register(id: AuthorId, name: AuthorName) { recorded += id to name } }
val result = CommandScenario(LibraryArcArtifactModule(), RecordAuthor::class.java) .addService(AuthorRegistration::class.java, registration) .execute(RecordAuthor(id, name))
result.shouldSucceed() assertEquals(listOf(id to name), recorded) }}import { CommandScenario, type ScenarioCommandResult } from '@cratis/arc.testing';import { deepStrictEqual } from 'node:assert';import { afterEach, beforeEach, describe, it } from 'vitest';import { AuthorId } from './AuthorId.js';import { AuthorName } from './AuthorName.js';import { AuthorRegistration, RecordAuthor, RecordAuthorValidator } from './RecordAuthor.js';
describe('when recording an author', () => { let scenario: CommandScenario<RecordAuthor>; let result: ScenarioCommandResult; let registered: string[][]; let id: AuthorId; let name: AuthorName;
beforeEach(async () => { registered = []; id = AuthorId.create(); name = new AuthorName('Ada Lovelace'); scenario = CommandScenario.for(RecordAuthor, RecordAuthorValidator); scenario.services.addSingleton(AuthorRegistration, { register: async (authorId: AuthorId, authorName: AuthorName) => { registered.push([authorId.toString(), authorName.value]); } }); result = await scenario.execute({ id, name }); });
afterEach(async () => { await scenario.dispose(); });
it('should succeed through the real command pipeline', () => { result.shouldBeSuccessful(); });
it('should register the author with the intended values', () => { deepStrictEqual(registered, [[id.toString(), name.value]]); });});In .NET, use Validate() when you want validation and authorization without running the handler. Use the Chronicle extension when the command returns events and you want to assert which facts were recorded.
How to test Chronicle
Section titled “How to test Chronicle”In .NET, Chronicle tests are fastest when you test the part of the event pipeline you own:
| You need to prove | Use |
|---|---|
| Code appends the right event, or rejects an append because of constraints | EventScenario |
| A projection or reducer builds the read model correctly | ReadModelScenario<TReadModel> |
| A reactor performs the right side effect when an event arrives | ReactorScenario<TReactor> |
Use a real Chronicle integration test when you are testing hosting, storage, subscriptions, observer recovery, or the boundary between processes. In .NET, Kotlin, and TypeScript, keep supported read-model behavior in the in-process scenarios so the suite stays fast.
How to test a Cratis stack slice
Section titled “How to test a Cratis stack slice”A full .NET slice usually has three useful spec levels:
| Level | What it proves | Typical tool |
|---|---|---|
| Command spec | The command validates, authorizes, and records the right facts | CommandScenario<TCommand> + Cratis.Arc.Chronicle.Testing |
| Read side spec | The event history builds the read model the screen needs | ReadModelScenario<TReadModel> |
| Workflow spec | The app boundary, identity, tenant routing, real Chronicle host, and projections work together | xUnit fixture + Cratis.Specifications.XUnit + HTTP/client helpers |
Start with command and read-side specs for every meaningful event-model column. Add workflow specs around the edges where the wiring matters: AuthProxy headers, tenant isolation, a real Chronicle server, or browser-visible behavior.
Where to go next
Section titled “Where to go next”Understand the BDD package itself: lifecycle methods, xUnit and NUnit packages, exception capture, assertions, and spec folder structure.
Arc testingTest commands through the Arc command pipeline, with or without Chronicle.
Chronicle testingTest events, read models, and reactors in-process.
Spec-driven developmentSee where these specs sit between the event model, Screenplay, and agents.
Event modelingDraw the flow that becomes the specification.
Build the full sliceSee the command, event, read model, and React screen together.