Skip to content

Testing Chronicle commands

An event-sourced command makes a decision and records it as events. A test for it answers three questions: what state did the command see, which events did it append, and what did it refuse. @cratis/arc.chronicle/testing has two scenarios for this, and this page covers the in-memory one. Both run the command through the real Arc pipeline: authorization, validation, provide(), handle(), and the Chronicle response handler.

ChronicleCommandScenarioChronicleKernelScenario
NeedsNothing; the event log is in memoryA running Chronicle kernel at ARC_CHRONICLE_TEST_URL
Runs in yarn testYesOnly when you opt in
State the command readsPinned models or reducer/flat projection state from seeded eventsEvents you seed with given.events, projected by the kernel
Aggregates (commandAggregate)Not supported; the command failsRehydrated from the seeded events
Constraints and concurrencyNot enforcedEnforced
ProjectionsSupported flat definitions evaluated on demand; see the Chronicle testing capabilitiesRun by the kernel; assert them with shouldHaveReadModel

Start with ChronicleCommandScenario. Most tests check the decision a command makes from the state it is given, and the in-memory scenario checks that in milliseconds. For snapshot queries injecting ChronicleReadModels, ChronicleQueryScenario reuses the same pin and seeded-history rules for keyed lookups. Move to ChronicleKernelScenario when the test depends on an aggregate, an unsupported projection operation, a constraint, or a concurrency check; Test Chronicle commands against a kernel covers it.

The examples on this page test a lending slice built on the Library sample. The slice is written for this page and is not part of the sample. It uses the sample’s Book read model, which a projection builds from BookAdded, and its BookId and BookTitle concepts. One file holds the command, its validator, and its events:

Features/Books/Lending/Lending.ts
import { field } from '@cratis/fundamentals';
import { eventType } from '@cratis/chronicle/events';
import { command, commandReadModel, CommandValidator, inject, key, validator } from '@cratis/arc.core';
import { BookId } from '../BookId.js';
import { BookTitle } from '../BookTitle.js';
import { Book } from '../Listing/Listing.js';
@eventType()
export class BookLent {
@field(BookTitle) title: BookTitle;
@field(String) member: string;
constructor(title: BookTitle = new BookTitle(''), member = '') {
this.title = title;
this.member = member;
}
}
@eventType()
export class ReturnDateSet {
@field(Number) days: number;
constructor(days = 0) { this.days = days; }
}
@command()
export class LendBook {
@key() @field(BookId) bookId!: BookId;
@field(String) member!: string;
@field(Number) days!: number;
@inject(commandReadModel(Book))
handle(book: Book): [BookLent, ReturnDateSet] {
return [new BookLent(book.title, this.member), new ReturnDateSet(this.days)];
}
}
@validator(LendBook)
export class LendBookValidator extends CommandValidator<LendBook> {
constructor() {
super();
this.ruleFor(command => command.member).notEmpty().withMessage('A member is required');
this.ruleFor(command => command.days).greaterThan(0).withMessage('A loan lasts at least one day');
}
}

LendBook reads the Book projected for its key and returns two events. Arc appends both to the book’s event source in one batch.

For an application that loads this slice, injecting an aggregate into a command shows useGeneratedMetadata and discover() together.

Register the event, read model, and its @reducer(..., undefined, ReadModel) type as artifacts. Seed events before execution:

Features/Accounts/for_CheckAccount/when_checking/with_opened_account.ts
import { field } from '@cratis/fundamentals';
import { eventType } from '@cratis/chronicle/events';
import { reducer } from '@cratis/chronicle/reducers';
import { readModel } from '@cratis/chronicle/readModels';
import { command, commandReadModel, inject, key } from '@cratis/arc.core';
import { ChronicleCommandScenario } from '@cratis/arc.chronicle/testing';
@eventType() class AccountOpened { @field(Number) balance: number; constructor(balance = 0) { this.balance = balance; } }
@readModel() class AccountBalance { @field(Number) balance = 0; }
@reducer('AccountBalanceReducer', undefined, AccountBalance)
class AccountBalanceReducer {
accountOpened(event: AccountOpened): AccountBalance { return { balance: event.balance }; }
}
@command() class CheckAccount {
@field(String) @key() id = '';
@inject(commandReadModel(AccountBalance))
handle(balance: AccountBalance): number { return balance.balance; }
}
const scenario = ChronicleCommandScenario.for(CheckAccount, AccountOpened, AccountBalance, AccountBalanceReducer);
scenario.given.forEventSource('account-1').events(new AccountOpened(25));
const result = await scenario.execute({ id: 'account-1' });
result.shouldBeSuccessful();
await scenario.dispose();

Here AccountBalanceReducer handles AccountOpened and produces the AccountBalance injected into CheckAccount. The SDK’s ReadModelScenario folds reducer history (since 6.14.0) and, starting in 6.19.0, evaluates supported flat projections from seeded events on demand for each source. The main @cratis/arc.chronicle entry supports @cratis/chronicle 6.29.0 and later. The scenario loads the SDK’s testing subpath only when it needs to fold seeded history. A different source has no balance; required commandReadModel(AccountBalance) rejects it and an optional read model receives null. Seeding does not appear in result.appendedEvents or scenario.appendedEvents. Later command appends are not folded into this scenario’s read models, matching the .NET command scenario’s seeded-history lookup. Use a kernel scenario to test observer updates caused by commands.

Seed history for a different tenant with scenario.given.forEventSource('account-1', 'tenant-a').events(...) and set that tenant in the command’s trusted context before executing. given.forEventSource(id).readModel(instance) pins state for the current tenant instead; givenReadModel(Type, id, instance, tenant?) also defaults to the current tenant. If you select a source before setting scenario.context.tenantId, the default tenant is resolved when you call .events(...) or .readModel(...). A pinned instance wins over history for its type, source and tenant. Projection seeds retain chronological order across source IDs, so projected EventContext.SequenceNumber reflects the order of .events(...) calls. Reducer seeds retain their existing per-source feeding order; interleaved sources can therefore have different sequence numbers in reducer contexts.

With @cratis/chronicle 6.19.0 or later, a projection-backed model with seeded history is evaluated in-process within the Chronicle testing capability boundary. An unsupported definition throws the SDK’s UnsupportedProjectionOperation unchanged; use ChronicleKernelScenario for that definition. Older SDKs require a kernel scenario for projection-backed state and report the required SDK version instead of fabricating a model. If a projection omits its read-model type, seeded lookups on older SDKs fail explicitly with @cratis/chronicle >= 6.19.0 and suggest @projection('', ReadModel) because Arc cannot tell which read model it targets without the evaluator. With no history for that source, the model is missing just like an unseeded reducer-backed model. A plain @readModel() without a reducer or projection is also missing when the SDK can rule out untyped projections for it (or none are registered), even if other models have seeded history. Aggregates also require the kernel scenario because the in-memory event log cannot replay their routed event history. This is not a substitute for constraints, concurrency, compliance or reactors.

givenReadModel(Type, sourceId, instance) sets the instance the scenario’s event store returns for that read model and ID. Pin it before execute:

Features/Books/Lending/for_LendBook/when_lending/with_a_book_in_the_catalog.ts
import { ChronicleCommandScenario } from '@cratis/arc.chronicle/testing';
import { AuthorId } from '../../../../Authors/AuthorId.js';
import { BookId } from '../../../BookId.js';
import { BookTitle } from '../../../BookTitle.js';
import { Book } from '../../../Listing/Listing.js';
import { BookLent, LendBook, LendBookValidator, ReturnDateSet } from '../../Lending.js';
describe('when lending a book in the catalog', () => {
const scenario = ChronicleCommandScenario.for(LendBook, BookLent, ReturnDateSet, LendBookValidator, Book);
const bookId = BookId.create();
let result: Awaited<ReturnType<typeof scenario.execute>>;
beforeAll(async () => {
const book = new Book();
book.id = bookId;
book.authorId = AuthorId.create();
book.title = new BookTitle('Kindred');
scenario.givenReadModel(Book, bookId.toString(), book);
result = await scenario.execute({ bookId, member: 'member-1', days: 14 });
});
afterAll(async () => { await scenario.dispose(); });
it('should append the loan and its return date to the book', () => {
result.shouldBeSuccessful();
result.appendedEvents.should.have.lengthOf(2);
result.shouldHaveAppendedEvent(BookLent, bookId.toString(),
event => event.title.value === 'Kindred' && event.member === 'member-1');
result.shouldHaveAppendedEvent(ReturnDateSet, bookId.toString(), event => event.days === 14);
});
it('should append the events in the order handle() returned them', () => {
result.appendedEvents.map(appended => appended.event.constructor).should.deep.equal([BookLent, ReturnDateSet]);
});
});

Pass everything the command touches to for(...): its event types, validators, and read model types. The in-memory log refuses an event type that is not registered. A read model type that is not registered fails the execution with Expected one read-model resolver for Book, found 0.

A pinned read model belongs to one ID and one tenant:

  • The tenant defaults to the current scenario.context.tenantId, or Default when the context sets none. Pass the tenant as the fourth argument to pin a model in a different tenant.
  • A read model that is neither pinned nor materialized from supported seeded history is missing. commandReadModel(Book) rejects the command, and commandReadModel(Book, { optional: true }) hands handle() a null.
  • A command that injects ChronicleReadModels and calls findInstanceById or getById receives the pinned instance for any ID, not only the command key. getAll and the observe methods are not backed by the in-memory store.

A pinned read model is a fixed value. The scenario does not evaluate history for that pinned type, and a later execution does not update it. To check observer-driven updates or projections outside the in-process capability boundary, use the kernel scenario.

A result carries the events appended by that execution:

  • result.appendedEvents lists them in the order the command returned them. Each entry has the event instance, its source, tenant, eventSourceType, eventStreamType, eventStreamId, subject, and tags.
  • result.shouldHaveAppendedEvent(Type, sourceId?, predicate?) passes when at least one of them matches. Call it once per event you expect.

shouldHaveAppendedEvent does not check how many events were appended or in which order. Assert the count on appendedEvents when an extra event would be a defect, and compare the event types when the order matters, as the example above does.

scenario.appendedEvents holds every event appended since the scenario was created. Use it only when a test runs several commands and asserts their combined output.

A rejected command appends nothing. Assert the reason and the empty result:

describe('when lending a book that is not in the catalog', () => {
const scenario = ChronicleCommandScenario.for(LendBook, BookLent, ReturnDateSet, LendBookValidator, Book);
let result: Awaited<ReturnType<typeof scenario.execute>>;
beforeAll(async () => { result = await scenario.execute({ bookId: BookId.create(), member: 'member-1', days: 14 }); });
afterAll(async () => { await scenario.dispose(); });
it('should reject the command without appending', () => {
result.shouldNotBeSuccessful();
result.shouldHaveValidationErrorFor('Book was not found for the command key');
result.appendedEvents.should.have.lengthOf(0);
});
});
describe('when lending a book for no days', () => {
const scenario = ChronicleCommandScenario.for(LendBook, BookLent, ReturnDateSet, LendBookValidator, Book);
let result: Awaited<ReturnType<typeof scenario.execute>>;
beforeAll(async () => { result = await scenario.execute({ bookId: BookId.create(), member: 'member-1', days: 0 }); });
afterAll(async () => { await scenario.dispose(); });
it('should reject the loan period without appending', () => {
result.shouldHaveValidationErrorForMember('days');
result.appendedEvents.should.have.lengthOf(0);
});
});

These blocks continue the spec file above. The second one pins no read model and still gets a clean validation failure, because validators run before Arc loads the read model for handle(). The command assertions work on every Chronicle result.

To run a command as a signed-in user, set the principal on scenario.context, as the Library sample’s RegisterAuthor spec does.

MemberMeaning
for(Command, ...artifacts)Create the scenario; pass event types, validators, and read models
contextTrusted request values, such as the principal and tenant
given.forEventSource(sourceId, tenant?).events(...events)Seed ordered history for a source and tenant (default: current context tenant)
given.forEventSource(sourceId, tenant?).readModel(instance)Pin a read model by its instance type
givenReadModel(Type, sourceId, instance, tenant?)Pin a read model instance; the tenant defaults to the current context tenant (or Default)
execute(values)Run the command; the result has the usual command assertions
result.appendedEventsThe events this execution appended
result.shouldHaveAppendedEvent(Type, sourceId?, predicate?)An event of that type was appended in this execution, optionally to a source and matching a predicate
scenario.appendedEventsEvery event appended since the scenario was created
dispose()Release the scenario; call it after every test

The in-memory log records the events a command appends, with their routing, subject, and tags, and it accepts concurrency scopes. Its tail sequence number counts command appends only, not seeded history. It does not enforce concurrency or constraints, run observers or reactors, load aggregates, or replace the kernel suite. Supported flat projections are evaluated on demand for seeded keyed reads, not kept up to date by command appends.

It can seed reducer and supported flat projection history, but an aggregate cannot load it, and commands do not update read-model state in this fixture. When the decision depends on unsupported projection or aggregate history, use the kernel scenario, which seeds a book’s loan and returns it through an aggregate. Source/Chronicle/run-integration.sh covers adapter-level integration checks.