Skip to content

Testing with scenarios

Use github.com/cratis/chronicle.go/chronicletest to arrange events and assert read-model state or reactor results in ordinary Go tests. This experimental kit uses the same registered-type discovery, serialization, fold plans and invoker as the client. It needs neither a dependency-injection container nor testify.

Choose the real kernel when the assertion concerns kernel behavior. Unlike C#‘s in-process fixtures, the Go substitute cannot load and run the C# kernel.

ScenarioExecutionWhat it does not prove
EventScenario, KernelProduction client registration, append and reads against a real serverEncryption support or multi-node behavior merely from a successful append
EventScenario, Substitute (zero-value engine)Production client over a private in-memory append/read transportConstraints, concurrency enforcement, hashing, schema migration/validation, encryption, authorization, persistence or observers
ReadModelScenario[M], reducerProduction fold plan in-process, one scope per sourceKernel delivery, storage, retries, quarantine or compliance
ReadModelScenario[M], projectionProduction compiler/encoder and real kernel bounded replay RPCMaterialized-sink catch-up, persistence or observer completion
ReactorScenario[R]Production discovery, scope activation, middleware and invoker; seeded read models and recording effectsTransport acceptance, durable checkpoints, retries, quarantine or exactly-once handling

Register the same named event types your application registers. Registration is explicit, isolated and snapshotted; there is no global assembly scan or alternate test discovery convention. The following test body assumes registry contains AccountOpened and ctx is your test context:

scenario := chronicletest.NewEventScenario(t, chronicletest.Config{
Registry: registry,
Engine: chronicletest.Substitute,
})
if err := scenario.Given(ctx, "account-1", AccountOpened{Name: "Ada"}); err != nil {
t.Fatal(err)
}
history, err := scenario.EventLog().ReadSource(ctx, "account-1", eventsequences.SourceFilter{})
if err != nil {
t.Fatal(err)
}
if len(history) != 1 {
t.Fatalf("got %d events, want 1", len(history))
}

New* helpers accept testing.TB, call t.Helper() and register cleanup. Open* constructors instead return (scenario, error) and require Close; use these outside tests and benchmarks. Complete compiling examples include all domain types, registrations and imports for each scenario kind.

Given appends in order using the production client. An error stops seeding; earlier committed events remain. Exercise production code with EventLog() or Store; there is no test-only append implementation on the client side. The substitute supports single append, same-source AppendMany, finite source and sequence reads, tail, next and has-events. Other RPCs fail explicitly.

The substitute installs the client’s no-check concurrency strategy, like C#‘s EventScenario; explicitly requested checks are refused, never reported as successful. Registries containing constraints, read models or live observers are refused before registration. Use the dedicated read-model/reactor fixtures for local tests of those plans, and a kernel event scenario for actual observers.

Set Engine: chronicletest.Kernel, ConnectionString to your test server, and Development: true only for its self-signed development certificate. Tests use cratis/chronicle:19.32.3-development. The fixture does not start Docker or silently fall back to a fake.

scenario := chronicletest.NewEventScenario(t, chronicletest.Config{
Registry: registry,
Engine: chronicletest.Kernel,
ConnectionString: os.Getenv("CHRONICLE_INTEGRATION_CONNECTION_STRING"),
Development: true,
})
chronicletest.RequireFidelity(t, scenario.Fidelity(), chronicletest.Constraints)

An omitted kernel endpoint skips with a clear message in New*; Open* returns ErrKernelUnavailable. A configured endpoint that cannot connect, authenticate or register fails, rather than hiding broken integration infrastructure as a skip. Set a context deadline for operations. Each default fixture creates a unique store; an explicitly supplied store must be dedicated to this test. Closing a scenario joins owned resources but does not delete server data.

For kernel constraint tests, register constraints definitions exactly as in production, append competing values and assert result.Err() and persisted history. The fixture does not implement a uniqueness validator in Go.

Register your model and reducer type with RegisterReadModel and RegisterReducer, or register a model-bound/fluent projection. The fixture selects the compiled producer for M. This excerpt uses the AccountReducer from the complete examples:

scenario := chronicletest.NewReadModelScenario[Account](t,
chronicletest.Config{Registry: registry})
if err := scenario.Given(ctx, "account-1",
AccountOpened{Name: "Ada"}, AccountRenamed{Name: "Grace"}); err != nil {
t.Fatal(err)
}
instance, err := scenario.Instance(ctx)
if err != nil {
t.Fatal(err)
}
if !instance.Exists || instance.Value.Name != "Grace" {
t.Fatalf("unexpected account: %+v", instance)
}

Reducer scenarios require Engine: Substitute (the default). Like C#‘s ReadModelScenario, they use the real fold invoker in-process over substituted storage. Engine: Kernel returns ErrFidelityUnavailable for a reducer rather than silently running locally. Use a kernel EventScenario to test append-time constraints or live reducer delivery.

Reducer results preserve absence/deletion and fail without partial state on fold errors. Unsubscribed events do not invoke folds. Distinct sources fold separately; Instance returns ErrAmbiguousInstance if more than one result exists. Choose InstanceFor(ctx, key) or Instances(ctx) instead. Reads replay the collected history, so keep folds deterministic. Synthetic sequence numbers increase across Given calls and retain gaps for unsubscribed events.

For a projection, use Engine: Kernel. Given appends to the definition’s selected sequence. Keep this registry free of other Go reactors/reducers: projection scenarios reject them before connecting, rather than accidentally executing unrelated side effects. Use a kernel EventScenario when live observers are the subject of the test. Result reads call the kernel’s bounded projection replay, not a second Go projection engine and not a sleep-based observer wait. The real kernel resolves joins and custom keys. With no seeded events, reads return empty results without an RPC, even if you explicitly borrow a populated store. Replay counts above math.MaxInt32 are rejected rather than wrapping the kernel’s signed limit. Instance counts all returned roots before selecting one. Keyed access requires the kernel response to include a nonempty string ID: declare a root ID field or chronicle:"key" with a kernel-compatible id/Id JSON name. If the schema drops the key, keyed access fails instead of guessing from seed order.

Pass ReadModelOptions[M]{Projection: &declaration} to replace a discovered projection or reducer for this fixture. This uses Registry.WithProjection to create a detached registry and then the ordinary production compiler. The original registry is unchanged. Models with explicitly conflicting producer/sink metadata still fail normal production validation. Initial: &model supports reducer initial state; projection initial state is explicitly unsupported.

Opt in when seeding an unrelated registered event should reveal a test mistake:

scenario := chronicletest.NewReadModelScenario[ProjectedAccount](t,
kernelConfig, chronicletest.ReadModelOptions[ProjectedAccount]{
StrictEventSubscription: true,
})
if err := scenario.Given(ctx, "account-1", AccountOpened{Name: "Ada"}); err != nil {
t.Fatal(err)
}

This excerpt assumes your registry contains the ProjectedAccount projection and AccountOpened, and kernelConfig selects your isolated kernel store. The compiling strict-subscription example shows registration and checks errors.Is(err, chronicletest.ErrUnsubscribedEventSeeded).

The default is false: unrelated registered seeds are still appended and ignored by projection processing. With strict mode, Given rejects an unsubscribed event immediately after registered-type lookup, before its codecs, outgoing providers, append or history mutation. The error identifies only the selected projection and event type IDs. Unknown event types still return chronicle.ErrNotRegistered. Reducers ignore the option and retain their existing unsubscribed-event filtering.

Check every Given error. C# raises its strict-subscription error during lazy result processing; Go deliberately raises from its existing error-returning Given, before remote append. Result reads never reintroduce a rejected seed. Earlier successful seeds remain, even when a later item in the same call fails. There is no batch rollback or promise that retrying a failed call is safe.

Strict membership follows the kernel’s key-resolver set from the selected compiled definition, by event type ID regardless of generation. Root and child From, Join and RemovedWith types count; RemovedWithJoin counts only on children, not at the root. Children contribute their full subtree recursively. Nested objects contribute From and RemovedWith, plus Join only outside a child subtree; nested join removals, children and event-property subscriptions do not contribute. Derivative-group and root/child event-property types also count. Keys and parent keys do not change membership. Pure or mixed All admits every registered type; ordinary Every mappings do not broaden the set. Inline replacement uses its selected definition, not the original producer.

Variants (even those with ordinary-looking wire fields), protected models and protected subscribed events fail before connecting. Every subscribed generation is checked, including child-only members. Projection initial state and nonempty defaults remain refused; the upstream initial-state barrier is missing. SeedReadModel is not a projected-state overlay. Strict scenarios with relationships or custom keys can open and check seeds, but result reads still return chronicle.ErrUnsupported under the separate replay admission profile. Strict seed admission does not make these projections replayable.

Strict seed checking does not prove observer attachment, partition/correlation routing, retries, durability or distributed completion. The pinned-kernel membership witness compares admitted seed IDs with the projection observer’s EventTypes and an independent golden set. It does not claim relationship delivery or replay success. The root source-key sibling checks persisted history and synchronous bounded replay, not asynchronous replay-job completion; #60 remains an unknown completion outcome. Projection seeding still serializes before production Append serializes again; this option does not change that pipeline.

SeedReadModel(key, value) on read-model and reactor scenarios supplies a separate snapshot for ReadModels() dependency reads. It does not seed projected state or write a sink. Reducer constructors can receive explicit collaborators through Config.Services or plain constructor closures, just as in production.

Register the reactor and its event/read-model dependencies normally, then seed the dependency needed by the handler. This uses WelcomeReactor from the examples:

scenario := chronicletest.NewReactorScenario[*WelcomeReactor](t,
chronicletest.Config{Registry: registry})
if err := scenario.SeedReadModel("account-1", Account{Name: "Chronicle"}); err != nil {
t.Fatal(err)
}
if err := scenario.Given(ctx, "account-1", AccountOpened{Name: "Ada"}); err != nil {
t.Fatal(err)
}
chronicletest.ShouldHaveProduced[WelcomeRequested](t, scenario.Produced(),
func(event WelcomeRequested) bool { return event.Message == "Welcome Ada to Chronicle" })
chronicletest.ShouldNotHaveProduced[AccountClosed](t, scenario.Produced())

Each Given opens a fresh production batch scope; PerEvent registrations retain their per-event activation policy. Constructor/handler failures and cleanup errors are returned, and handler panics become errors through the invoker. Synthetic reactors.Delivery identities are monotonic across calls, including failures. Unsubscribed registered events advance the synthetic sequence but do not activate scopes, invoke handlers or run middleware. They are not a simulation of kernel replay or retry identities.

The recorder flattens collections, eventsequences.Entry and EventsWithConcurrencyScopes to their payloads. It records returned commands without running their registered executors. Custom declared return types still need the production side-effect handler registration. Production signature validation and runtime effect classification run before recording; unregistered payloads, nil collection items, unclaimed nested collections and invalid event wrappers fail without partial recording. OpenReactorScenarioForID and NewReactorScenarioForID select explicit callback registrations without a Go artifact type.

Produced() copies the outer slice but borrows payload objects. Do not mutate those objects while asserting. Nested/cyclic collections are bounded at depth 64. A green Produced assertion proves only a classified returned payload, not serialization or transport acceptance: no event was appended and no command executor ran. Direct side effects inside your handler still run; inject test collaborators for those yourself.

Inspect scenario.Fidelity().Substitutions() and use RequireFidelity before assertions that depend on a particular layer. Fidelity.Require is the error-returning equivalent. Unknown layer names and any substituted layer fail with ErrFidelityUnavailable; requiring all layers cannot silently succeed. Layer availability identifies the execution boundary, not proof of a specific behavior. You must still assert a real observable outcome.

A zero Fidelity also refuses every requirement. Kernel generation/schema validation is not enabled by these fixtures and remains an unclaimed layer.

All fixtures honor Config.Naming; zero preserves production Go property names. Select chronicletest.CSharpScenarioNaming explicitly for Fundamentals camelCase (the C# scenario default, retaining leading acronyms). Explicit JSON tags win. Registry compilation still rebinds schema, constraint and projection paths by field identity. Naming does not change engine fidelity.

The chosen boundary keeps production discovery and client execution shared while leaving projection evaluation and invariant enforcement in their owning kernel. An all-Go projection interpreter was rejected: it would be a second implementation whose passing tests could disagree with Chronicle. The in-memory transport instead dispatches directly to a deliberately small append/read substitute and rejects unsupported operations. It copies protobuf messages across this boundary but hosts no gRPC server and does not exercise HTTP/2.

C# can load the real kernel into its process; Go cannot claim that fidelity from an in-memory map. Local scenarios therefore do not prove protection/key erasure, durable storage, retries/quarantine or multi-node semantics. Even a real-kernel projection replay is not evidence that a live observer caught up. Projection fixtures also report delivery metadata and effect acceptance as unavailable: they neither receive reactor deliveries nor execute returned effects. The kernel-backed siblings separately exercise projection replay, constraint rejection and live reactor lifecycle. Full protected-data scenario coverage awaits the SDK’s compliance surface; see the scenario parity map.