Skip to content

Testing

Specifying a Chronicle-backed slice used to mean driving the real client against a running event store: a kernel, a sink, Docker, and a test that takes seconds instead of milliseconds. That is an integration test, and it has its place — but it is a poor place to specify “given these three events, the state should be this”.

io.cratis:chronicle-testing runs the same code with nothing behind it.

dependencies {
testImplementation("io.cratis:chronicle-testing:<version>")
}

EventScenario hands your code an IEventSequence that keeps events in a list, then lets you ask what landed in it:

import io.cratis.chronicle.testing.EventScenario
import io.cratis.chronicle.testing.shouldHaveAppended
import io.cratis.chronicle.testing.shouldHaveAppendedExactly
@Test
fun `registering an employee records that they were hired`() = runBlocking {
val scenario = EventScenario()
Registrations(scenario.eventLog)
.register("employee-1", "Ada", "Lovelace")
scenario.shouldHaveAppended<EmployeeHired>("employee-1") {
it.firstName == "Ada"
}
scenario.shouldHaveAppendedExactly(1)
}

given appends preconditions — what was already true before the code under test ran — so an assertion about what it appended does not have to count them:

scenario.given(
"employee-1",
EmployeeHired("Ada", "Lovelace", "Engineer")
)
Registrations(scenario.eventLog).promote("employee-1", "Principal")
scenario.shouldHaveAppended<EmployeePromoted>("employee-1")
AssertionFails unless
shouldHaveAppended<T>(source) { }One T matching the condition landed
shouldHaveAppendedExactly(count)Exactly count events landed in total
shouldHaveAppendedExactly<T>(count)Exactly count events of T landed
shouldNotHaveAppended<T>()No T was appended
shouldHaveAppendedNothing()Nothing at all was appended

The assertions exist for their failure messages rather than for the check — a failure prints every event that was appended, with its event source and its JSON, which is usually enough to see the problem without a debugger.

A reducer is a fold: events in, read model out. ReadModelScenario runs it the way the client runs it against a real kernel — handlers discovered the same way, chosen by event type the same way, invoked with the same shapes, awaited if they suspend:

import io.cratis.chronicle.testing.ReadModelScenario
@Test
fun `promotion changes the title and keeps the name`() = runBlocking {
val scenario = ReadModelScenario<EmployeeState>(EmployeeStateReducer())
val state = scenario.fold(
"employee-1",
EmployeeHired("Ada", "Lovelace", "Engineer"),
EmployeePromoted("Principal Engineer")
)
assertEquals("Ada", state!!.firstName)
assertEquals("Principal Engineer", state.title)
}

Chronicle folds each event source independently, and so does this — state does not leak between event sources, which is exactly the mistake a reducer spec should be able to catch. stateFor(eventSourceId) reads back what each one folded to, and folding the same event source again continues from where it left off, so a history can be built in stages.

Real:

  • The client’s own serializer. Every event round-trips through it on the way in, so an event that could not survive a trip to the kernel fails here too — and so does a reducer that only works on an instance it was handed directly.
  • Event contexts. Sequence numbers, event source ids, stream type and id, tags and the time it occurred are filled in the way the kernel fills them.
  • Handler dispatch. The same discovery, the same shapes, the same suspend support.

Not real, and deliberately so:

  • Constraints. Nothing is enforced, so every append succeeds. A constraint is the kernel’s job and belongs in an integration test.
  • Projections. Only reducers fold in-process. A projection is a declaration the kernel executes.
  • Concurrency scopes. Accepted and ignored.
  • Observation. Nothing observes, so reactors do not run and there is no replay.

When a spec needs any of those, it needs a kernel — and at that point it is an integration test, which is a different and more expensive thing on purpose.