From script to stage · Part 11: Testing with Arc and Chronicle
Cratis: from script to stage, and the long run · Part 11 of 26
A spec for “a second author with the same name is rejected” can pass without the first author ever reaching the event log. On an Arc CommandScenario, Given seeds an in-memory read model, and nothing it seeds is appended to the log that Chronicle checks a [Unique] constraint against. The spec sits in the right folder and carries the right name, and the constraint it’s named after is never exercised.
That’s one of a handful of ways a spec in an event-sourced application can go green and prove nothing, and they all come from losing track of what a given kind of test actually runs. Cratis offers several kinds, from calling a method to running a real Chronicle in Docker, and for most rules the cheapest one is enough.
Start with the function
Section titled “Start with the function”The decision in RegisterAuthor is a method. Call Handle(), compare the event it returns, and the rule is tested without starting a server or faking an event log. Arc’s testing guide makes that direct call the first choice for a deterministic decision:
#if DEBUGpublic class when_deciding_to_register_an_author : Specification{ readonly AuthorName _name = "Jane Austen"; AuthorRegistered _event = null!;
void Because() => _event = new RegisterAuthor(AuthorId.New(), _name).Handle();
[Fact] void should_record_the_name() => _event.Name.ShouldEqual(_name);}#endifThis spec uses JUnit; Cratis Specifications is a .NET library.
class RegisterAuthorTest { private val name = AuthorName("Jane Austen")
@Test fun `records the name`() { val event = RegisterAuthor(AuthorId.new(), name).handle()
assertEquals(name, event.name) }}This spec uses JUnit; Cratis Specifications is a .NET library.
class RegisterAuthorTest { private final AuthorName name = new AuthorName("Jane Austen");
@Test void recordsTheName() { var event = new RegisterAuthor(AuthorId.newId(), name).handle();
assertEquals(name, event.name()); }}This spec uses Vitest.
describe('when deciding to register an author', () => { const name = new AuthorName('Jane Austen'); let event: AuthorRegistered;
beforeEach(() => { event = Object.assign(new RegisterAuthor(), { id: AuthorId.create(), name }).handle(); });
it('should record the name', () => { event.name.should.equal(name); });});Arc for Elixir doesn’t exist; use the Chronicle Elixir client directly.
The C# spec is written with Cratis Specifications, where Because() is the “when” and each [Fact] is a “then”. It needs no container and no fake.
An event is a record, so expecting one is an equality check on a value. The event doesn’t even repeat its own event source ID, because Chronicle keeps that in the event context, so the value Handle() returns is all there is to compare. The event-sourced commands lesson puts every branch of a decision in direct specs like this one.
Arc also splits getting data from deciding with it. Provide() fetches what a command needs, and Handle() decides with what it was given, so Handle can be a pure function of its arguments. Arc’s loan example gets a credit score in Provide() and rejects an applicant with no credit history, as in Validation all the way down. In each language below, the direct spec supplies the score to Handle() or handle() and doesn’t exercise that rejection:
[Command]public record AssessLoan(LoanId LoanId, ApplicantId Applicant){ public Result<CreditScore, ValidationResult> Provide(ICreditBureau bureau) { var score = bureau.GetScore(Applicant); return score is null ? ValidationResult.Error("No credit history", ["applicant"]) : score; }
public LoanAssessment Handle(CreditScore creditScore) => new(LoanId, creditScore);}
#if DEBUGpublic class when_assessing_a_loan : Specification{ readonly LoanId _loanId = LoanId.New(); LoanAssessment _assessment = null!;
void Because() => _assessment = new AssessLoan(_loanId, ApplicantId.New()).Handle(new CreditScore(800));
[Fact] void should_return_the_assessment() => _assessment.ShouldEqual(new LoanAssessment(_loanId, new CreditScore(800)));}#endif@Commanddata class AssessLoan(val loanId: LoanId, val applicant: ApplicantId) { suspend fun provide(bureau: CreditBureau): Any = bureau.score(applicant) ?: ValidationResult.error("No credit history", listOf("applicant"))
fun handle(creditScore: CreditScore): LoanAssessment = LoanAssessment(loanId, creditScore)}
class AssessLoanTest { @Test fun `handle returns the assessment`() { val loanId = LoanId(UUID.randomUUID()) val command = AssessLoan(loanId, ApplicantId(UUID.randomUUID()))
val assessment = command.handle(CreditScore(800))
assertEquals(LoanAssessment(loanId, CreditScore(800)), assessment) }}@Commandpublic record AssessLoan(LoanId loanId, ApplicantId applicant) { public Object provide(CreditBureau bureau) { var score = bureau.score(applicant); return score == null ? ValidationResult.error("No credit history", List.of("applicant")) : score; }
public LoanAssessment handle(CreditScore creditScore) { return new LoanAssessment(loanId, creditScore); }}
class AssessLoanTest { @Test void handleReturnsTheAssessment() { var loanId = new LoanId(UUID.randomUUID()); var command = new AssessLoan(loanId, new ApplicantId(UUID.randomUUID()));
var assessment = command.handle(new CreditScore(800));
assertEquals(new LoanAssessment(loanId, new CreditScore(800)), assessment); }}From Arc for TypeScript, an early source preview whose packages aren’t on npm yet:
@command()export class AssessLoan { @field(LoanId) loanId!: LoanId; @field(ApplicantId) applicant!: ApplicantId;
// provide() takes no parameters, so it resolves its services from the execution scope. async provide(): Promise<CreditScore | Outcome<never>> { const bureau = await currentServices().resolve(CreditBureau); const score = await bureau.findScore(this.applicant); return score ?? rejected(validation('No credit history', ['applicant'])); }
handle(creditScore: CreditScore): LoanAssessment { return new LoanAssessment(this.loanId, creditScore); }}
describe('when assessing a loan', () => { it('should return the assessment', () => { const command = new AssessLoan(); command.loanId = LoanId.create(); command.applicant = ApplicantId.create();
command.handle(new CreditScore(800)).should.deep.equal(new LoanAssessment(command.loanId, new CreditScore(800))); });});Arc for Elixir doesn’t exist; use the Chronicle Elixir client directly.
The command decisions lesson tests one command both ways, once directly and once through the pipeline. A Handle() that calls a service or reads the clock is still testable, but it isn’t a pure function just because it lives on a command.
Reducers work the same way. A reducer takes the event, the current state and the event context, and returns the next state. In .NET, the Processing sample tests its progress reducer by folding four events by hand, trimmed here to the action:
var reducer = new WorkItemProgressReducer();_result = reducer.Opened(new WorkItemOpened("Prepare release", 10), null, ContextFor(0));_result = reducer.Recorded(new ProgressRecorded(4), _result, ContextFor(1));_result = reducer.Recorded(new ProgressRecorded(5), _result, ContextFor(2));_result = reducer.Recorded(new ProgressRecorded(3), _result, ContextFor(3));// then: _result.ShouldEqual(new WorkItemProgress(_workItemId, 10, 10, WorkPoints.NotSet, new EventSequenceNumber(3)));The same fold against a Kotlin reducer, which takes the event, the current state and the event context.
val reducer = WorkItemProgressReducer()result = reducer.opened(WorkItemOpened(WorkItemTitle("Prepare release"), WorkPoints(10)), null, contextFor(0))result = reducer.recorded(ProgressRecorded(WorkPoints(4)), result, contextFor(1))result = reducer.recorded(ProgressRecorded(WorkPoints(5)), result, contextFor(2))result = reducer.recorded(ProgressRecorded(WorkPoints(3)), result, contextFor(3))// then: assertEquals(WorkItemProgress(workItemId, WorkPoints(10), WorkPoints(10), WorkPoints.NOT_SET, 3), result)The same fold against a Java reducer class.
var reducer = new WorkItemProgressReducer();result = reducer.opened(new WorkItemOpened(new WorkItemTitle("Prepare release"), new WorkPoints(10)), null, contextFor(0));result = reducer.recorded(new ProgressRecorded(new WorkPoints(4)), result, contextFor(1));result = reducer.recorded(new ProgressRecorded(new WorkPoints(5)), result, contextFor(2));result = reducer.recorded(new ProgressRecorded(new WorkPoints(3)), result, contextFor(3));// then: assertEquals(new WorkItemProgress(workItemId, new WorkPoints(10), new WorkPoints(10), WorkPoints.NOT_SET, 3), result);The same fold against a TypeScript reducer, whose method is named for its event class in camelCase.
const reducer = new WorkItemProgressReducer();result = reducer.workItemOpened(new WorkItemOpened(new WorkItemTitle('Prepare release'), new WorkPoints(10)), undefined, contextFor(0));result = reducer.progressRecorded(new ProgressRecorded(new WorkPoints(4)), result, contextFor(1));result = reducer.progressRecorded(new ProgressRecorded(new WorkPoints(5)), result, contextFor(2));result = reducer.progressRecorded(new ProgressRecorded(new WorkPoints(3)), result, contextFor(3));// then: result.should.deep.equal(new WorkItemProgress(workItemId, new WorkPoints(10), new WorkPoints(10), WorkPoints.notSet, 3))The same fold through the reduce/3 callback of an Elixir reducer.
result = WorkItemProgressReducer.reduce(%WorkItemOpened{title: "Prepare release", points: 10}, nil, context_for(0))result = WorkItemProgressReducer.reduce(%ProgressRecorded{points: 4}, result, context_for(1))result = WorkItemProgressReducer.reduce(%ProgressRecorded{points: 5}, result, context_for(2))result = WorkItemProgressReducer.reduce(%ProgressRecorded{points: 3}, result, context_for(3))# then: assert result == %WorkItemProgress{work_item_id: work_item_id, target: 10, recorded: 10, sequence_number: 3}In .NET, the same sample calls its reactor directly. new CompletionSummaryReactor().Completed(new WorkItemCompleted(8, 8)) returns a CompletionSummarized event, with no event log and no command pipeline injected into the reactor. Chronicle’s reactor testing guide makes that split the default: the logic goes into a function that returns its side effect as a value, and the framework contract gets its own scenario. The default holds for as long as your reactors return their side effects instead of performing them.
Projections are the exception. A projection is attributes or a fluent definition, and there’s no method to call. The cheapest honest test of a projection runs the real projection engine, in memory.
One process, the real pipeline
Section titled “One process, the real pipeline”A spec for registering an author looks like this. Here RegisterAuthor takes the author’s id and name:
#if DEBUGpublic class when_registering_author : Specification{ readonly AuthorId _authorId = AuthorId.New(); readonly CommandScenario<RegisterAuthor> _scenario = new(); CommandResult _result = default!;
async Task Because() => _result = await _scenario.Execute(new RegisterAuthor(_authorId, "Jane Austen"));
[Fact] void should_succeed() => _result.ShouldBeSuccessful();
[Fact] async Task should_have_appended_registered_event() => await _scenario.ShouldHaveAppendedEvent<RegisterAuthor, AuthorRegistered>(_authorId, e => e.Name == "Jane Austen");}#endifIn Arc for Kotlin and Java, CommandScenario from io.cratis:arc-testing runs the real command pipeline, and the Chronicle integration gives it an in-memory event log.
class RegisterAuthorScenarioTest { private val authorId = AuthorId.new()
@Test fun `registers the author`() = runBlocking { val scenario = CommandScenario(module, RegisterAuthor::class.java)
val result = scenario.execute(RegisterAuthor(authorId, AuthorName("Jane Austen")))
result.shouldSucceed() scenario.chronicle().shouldHaveAppendedEvent(authorId.value.toString(), AuthorRegistered::class.java) }}In Arc for Kotlin and Java, CommandScenario from io.cratis:arc-testing runs the real command pipeline, and the Chronicle integration gives it an in-memory event log.
class RegisterAuthorScenarioTest { private final AuthorId authorId = AuthorId.newId();
@Test void registersTheAuthor() { CommandScenario<RegisterAuthor> configured = new CommandScenario<>(module, RegisterAuthor.class); ChronicleCommandScenario chronicle = ChronicleCommandScenarios.chronicle(configured);
try (BlockingCommandScenario<RegisterAuthor> scenario = new BlockingCommandScenario<>(configured)) { scenario.execute(new RegisterAuthor(authorId, new AuthorName("Jane Austen"))).shouldSucceed(); }
chronicle.shouldHaveAppendedEvent(authorId.value().toString(), AuthorRegistered.class); }}In Arc for TypeScript, ChronicleCommandScenario from @cratis/arc.chronicle/testing runs the real command pipeline over an in-memory event log.
describe('when registering author', () => { const authorId = AuthorId.create(); let scenario: ChronicleCommandScenario<RegisterAuthor>; let result: Awaited<ReturnType<typeof scenario.execute>>;
beforeEach(async () => { scenario = ChronicleCommandScenario.for(RegisterAuthor, AuthorRegistered); result = await scenario.execute({ id: authorId, name: new AuthorName('Jane Austen') }); });
afterEach(async () => { await scenario.dispose(); });
it('should succeed', () => { result.shouldBeSuccessful(); });
it('should have appended registered event', () => { result.shouldHaveAppendedEvent(AuthorRegistered, authorId.toString()); });});Arc for Elixir doesn’t exist; use the Chronicle Elixir client directly.
It runs Arc’s command pipeline and an in-memory Chronicle event log in one process, without a Chronicle server, and checks the decision and the recorded fact. HTTP routing, the auth middleware, a real read-model store and redelivery need a different kind of test. In .NET, Specifications supplies the given/when/then base on xUnit or NUnit. Synopsis can turn recognized specification shapes into navigable HTML documentation, though it doesn’t run them.
In .NET, CommandScenario<TCommand> runs authorization, the validators, Provide() and Handle(), and builds the command result, which is the path a request takes once routing has found the command. QueryScenario<TReadModel> does the same for a static query on a read model. With Cratis.Arc.Chronicle.Testing referenced, a command scenario gains Given, EventLog and AppendedEvents, and that’s where ShouldHaveAppendedEvent comes from. Arc for Kotlin and Java have a CommandScenario and a QueryScenario in io.cratis:arc-testing, and the source preview of Arc for TypeScript has a CommandScenario.
A direct spec and a scenario spec answer different questions about the same command. The direct one says the decision is right for the inputs you chose. The scenario says the command still reaches that decision when authorization, validation and Provide() run in front of it, and that the event it returned was appended.
Chronicle’s own scenarios
Section titled “Chronicle’s own scenarios”In .NET, Chronicle’s testing package, Cratis.Chronicle.Testing, runs the real projection and reducer engines, constraint evaluation and reactor dispatch over in-memory storage. Three scenarios divide the work:
| Scenario | Runs | Doesn’t reach |
|---|---|---|
EventScenario |
Appends, constraints and sequencing | Network serialization, the kernel connection, storage writes |
ReadModelScenario<T> |
A projection or reducer building state from events | Sink writes, MongoDB queries, change streams |
ReactorScenario<T> |
Dispatch, dependency injection, read-model materialization, routing of side effects | Observer registration, kernel dispatch, redelivery |
In .NET, the Author read model from the hub declares its projection with [FromEvent<AuthorRegistered>], and a ReadModelScenario picks up that model-bound form on its own, as it does a fluent projection or a reducer:
#if DEBUGpublic class when_an_author_is_registered : Specification{ readonly AuthorId _authorId = AuthorId.New(); readonly AuthorName _name = "Jane Austen"; readonly ReadModelScenario<Author> _scenario = new();
async Task Because() => await _scenario.Given.ForEventSource(_authorId).Events(new AuthorRegistered(_name));
[Fact] void should_have_the_name() => _scenario.Instance!.Name.ShouldEqual(_name);}#endifThe Kotlin client’s ReadModelScenario folds a reducer.
class AuthorReducerTest { private val authorId = AuthorId.new() private val name = AuthorName("Jane Austen")
@Test fun `the author has the name`() = runBlocking { val scenario = ReadModelScenario<Author>(AuthorReducer())
val author = scenario.fold(authorId.value.toString(), AuthorRegistered(name))
assertEquals(name, author!!.name) }}The Java client calls the reducer method directly.
class AuthorReducerTest { private final AuthorName name = new AuthorName("Jane Austen");
@Test void theAuthorHasTheName() { var reducer = new AuthorReducer();
var author = reducer.registered(new AuthorRegistered(name));
assertEquals(name, author.name()); }}The TypeScript client’s ReadModelScenario evaluates a @fromEvent model-bound projection. It refuses protected fields, including fields whose concept is marked @pii(). The series’ AuthorName keeps that marking and needs a kernel-backed test. This spec uses separate, unprotected test fixtures and checks only projection behavior, not personal-data handling.
class TestAuthorName extends ConceptAs<string> { static readonly valueType = String;}
@eventType()class TestAuthorRegistered { @field(TestAuthorName) @unique('UniqueAuthorName') name: TestAuthorName; constructor(name = new TestAuthorName('')) { this.name = name; }}
@fromEvent(TestAuthorRegistered)class TestAuthor { @field(AuthorId) id!: AuthorId; @field(TestAuthorName) name = new TestAuthorName('');}
describe('when an author is registered', () => { const authorId = AuthorId.create(); const name = new TestAuthorName('Jane Austen'); const scenario = new ReadModelScenario(TestAuthor);
beforeEach(async () => { await scenario.given.forEventSource(authorId.toString()).events(new TestAuthorRegistered(name)); });
it('should have the name', async () => { const author = await scenario.instance; author!.name.should.deep.equal(name); });});Not available in the Elixir client yet.
This is the uniqueness rule’s natural home too. In .NET, an EventScenario appends to its in-memory event log, so a first registration seeded through its Given is in the log the constraint is checked against, and a second append with the same name is rejected there. Assert the violation by the constraint’s name with ShouldHaveConstraintViolationFor:
[EventType]public record AuthorRegistered([property: Unique(name: "UniqueAuthorName")] AuthorName Name);
#if DEBUGpublic class when_registering_a_taken_author_name : Specification{ readonly EventScenario _scenario = new(); AppendResult _result = default!;
Task Establish() => _scenario.Given.ForEventSource(AuthorId.New()).Events(new AuthorRegistered("Jane Austen"));
async Task Because() => _result = await _scenario.When.ForEventSource(AuthorId.New()).Events(new AuthorRegistered("Jane Austen"));
[Fact] void should_be_rejected() => _result.ShouldBeFailed();
[Fact] void should_report_the_constraint() => _result.ShouldHaveConstraintViolationFor("UniqueAuthorName");
void Destroy() => _scenario.Dispose();}#endifThe Kotlin client’s EventScenario doesn’t check constraints.
The Java client has no in-memory scenario that checks constraints.
This spec uses Vitest and the same TestAuthorName and TestAuthorRegistered fixtures. Since @cratis/chronicle 6.31.4, EventScenario accepts string and boolean concepts, comparing their serialized scalar values. Its content still has to fit the supported flat, unprotected string/boolean schema; numeric, Guid, date and nested object fields remain unsupported. It also refuses the series’ @pii()-marked AuthorName, so testing the real AuthorRegistered still needs a kernel. This fixture tests uniqueness without personal-data handling.
class TestAuthorName extends ConceptAs<string> { static readonly valueType = String;}
@eventType()class TestAuthorRegistered { @field(TestAuthorName) @unique('UniqueAuthorName') name: TestAuthorName; constructor(name = new TestAuthorName('')) { this.name = name; }}
describe('when registering a taken author name', () => { let result: AppendResult;
beforeEach(async () => { const scenario = new EventScenario({ artifacts: { eventTypes: [TestAuthorRegistered] } }); await scenario.given.forEventSource(AuthorId.create().toString()).events(new TestAuthorRegistered(new TestAuthorName('Jane Austen'))); result = await scenario.when.forEventSource(AuthorId.create().toString()).event(new TestAuthorRegistered(new TestAuthorName('Jane Austen'))); });
it('should be rejected', () => { result.isSuccess.should.be.false; });
it('should report the constraint', () => { result.constraintViolations.map(violation => violation.constraintId).should.contain('UniqueAuthorName'); });});Not available in the Elixir client yet.
The same helper exists on a CommandResult and on an append result.
In .NET, ReactorScenario delivers each event once. To check that a reactor is idempotent, deliver the same event twice yourself and look at what happened the second time.
Outside .NET the scenarios are fewer. The Kotlin client has EventScenario and a ReadModelScenario for reducers. TypeScript has all three, with ReadModelScenario covering reducers and the projections it supports. Java tests appends and calls reducers directly, and Elixir has no scenarios. Beyond .NET covers the clients themselves.
Green, and proving nothing
Section titled “Green, and proving nothing”In-process scenarios are fast, so they’re trusted, and a few mistakes make them pass without testing anything. Three are easy to make with the author feature.
The first is a discarded assertion. Chronicle’s ShouldHaveAppendedEvent and ShouldHaveTailSequenceNumber on an event sequence are genuinely asynchronous. In a void fact that doesn’t await them, a failure lands on a Task that nobody observes, and the spec passes whatever the code did. The compiler doesn’t warn about it outside an async method. Make the fact async Task and await every Should* call that returns a task. Chronicle’s analyzer, CHR0039, reports the ones you miss, for Chronicle’s and Arc’s testing packages alike.
The second is a read model nobody seeded. When a validator, Provide() or Handle() asks for a read model that doesn’t exist for the command’s key, no rule runs at all, and the command is still rejected, with a single DependencyUnavailable validation result. ShouldNotBeSuccessful() passes, and it keeps passing after the rule the spec is named after has been deleted. ShouldHaveValidationErrors() refuses a result whose only errors carry that reason. An unhappy-path spec asserts both ShouldNotBeSuccessful() and ShouldHaveValidationErrors(), and a spec about authorization asserts ShouldNotBeAuthorized().
The third is the one from the opening. Given on a CommandScenario materializes read models. A precondition the constraint has to see goes through the scenario’s EventScenario.Given, which appends it to the in-memory log before the command runs.
Two more are worth knowing. Validate(command) runs the command filters, and authorization comes before validation, so a validate-only spec on an [Authorize] command with no identity registered is denied before its rule runs. And an expected value computed in the spec with the same expression as the code under test agrees with it by construction. Write the literal.

Three green specs, and what each one never reached.
Examples in the model
Section titled “Examples in the model”A .play slice in Screenplay can carry given/when/then examples of its own, written against the model. Adapted from the Screenplay docs’ example, with for naming the event source instead of a property on the event:
specification RegisteringADraftInvoice given CustomerRegistered for "3fa85f64-5717-4562-b3fc-2c963f66afa6" name = "Acme Corp" when RegisterInvoice invoiceId = "9c858901-8a57-4791-81fe-4c455b099bc9" customerId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" then InvoiceRegistered for "9c858901-8a57-4791-81fe-4c455b099bc9" customerId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"A then can also name a read model, a query result, an error or a denial. The experimental Stage specification runner, a container job, compiles the .play files, checks each specification against the model and writes the outcome to results.json. With the default engine, nothing else starts: no Arc, no Chronicle, no database and no network. The opt-in semantic engine instead runs commands through Arc’s in-memory command pipeline, against an in-memory Chronicle event log. It shows that the example fits the model, and it says nothing about whether the C# behaves that way. Reactions aren’t executed at the model level either. Screenplay and Stage and Scene go into the model side.
A real Chronicle and a running host
Section titled “A real Chronicle and a running host”Some behavior only exists with a real host. Routing, authentication middleware, real storage writes, change streams, redelivery and tenant routing from AuthProxy’s headers all sit outside one process with in-memory storage.
For .NET, Cratis.Chronicle.XUnit.Integration has two fixtures. ChronicleInProcessFixture runs the kernel inside the test process with MongoDB in a container through Testcontainers, and ChronicleOutOfProcessFixture runs the kernel in a container of its own. Both need Docker. A real kernel processes asynchronously, so IEventAppendCollection collects appends as they happen, and WaitForCount waits for the number you expect.
Arc has no packaged test host. A hosted spec is an HTTP client pointed at a running application, next to a Chronicle fixture that a project defines as an xUnit collection. The Library sample does this for the author feature. Its and_there_already_exists_one_with_same_name spec appends an existing author straight to the event log, posts a RegisterAuthor with the same name to /api/authors/register, and asserts that the command failed. It expects the application to be running on localhost:8080.
That spec covers the whole uniqueness rule end to end, and it costs Docker, a kernel shared across tests and a slower run.
The frontend
Section titled “The frontend”The generated proxies aren’t something to test. They’re output, and a change in the backend shows up in them on the next build. What’s worth a spec on the frontend is the code you wrote around them.
The convention Cratis recommends for application frontends mirrors the backend. Specs use Vitest, Sinon and Chai’s should, and files follow for_<Subject>/when_<context>/and_<extra>.ts. View models are plain classes you can construct without React, pure helpers get tested first, and there are no snapshots.
Test generated validation for the forms that depend on it, especially the omissions and dropped conditions in Validation all the way down. On the server side of TypeScript, Arc for TypeScript has a testing package, @cratis/arc.testing, and like the rest of Arc for TypeScript it’s a source preview.
Specs that read as sentences
Section titled “Specs that read as sentences”Given, when and then map straight onto event sourcing. Given is the facts that already happened, or the read-model state that follows from them. When is the command, the append or the event under test. Then is the events that were appended, the command result, the read model or the side effect.
Folder names carry the same structure. A spec at for_RegisterAuthor/when_registering/and_the_name_is_taken.cs reads as a sentence about the feature, and a folder of them reads as its behavior. The word when appears only in when_ folder names, and each should_ fact finishes the sentence. One behavior goes in each spec. Logging, trivial getters and plain delegation don’t get specs of their own.
Specifications gives that shape to .NET on xUnit or NUnit, in the style of Machine.Specifications: Establish() is the given, Because() the when, the facts the then, and Destroy() cleans up. The methods are optional, can return void or Task, and run across the inheritance chain. None of it is required. Chronicle’s scenarios work with any test framework, and a plain xUnit test can call Handle() just as well.
How much of each
Section titled “How much of each”
Cheapest first. Most specs belong on the first two steps.
Start the author suite with direct specs for each branch of the RegisterAuthor decision. One CommandScenario spec shows the command still decides that way with validation and authorization in front of it and appends what it returned. A ReadModelScenario covers the Author projection, and an EventScenario covers the [Unique] constraint with the first author actually in the log. One hosted spec sends the same name twice through a running application.
All but the last run in one process with nothing to start. The hosted spec is the only one that needs Docker, a kernel and a running application.
- Previous: Part 10 — Tenancy and identity end to end
- Next: Part 12 — Beyond .NET