Alpha Version: You are viewing the ALPHA documentation. This is an experimental version and may contain breaking changes.
Skip to main content

Given-When-Then Testing in Reventless

Reventless ships a dedicated Given-When-Then (GWT) test framework, @reventlessdev/reventless-gwt, with a DSL for every Event Modeling slice type, a runner-agnostic Outcome algebra, and a standalone CLI runner (reventless-gwt) with five output formats (human, JSON, TAP, JUnit, VS Code Testing API).

This guide is the canonical reference for writing slice-level tests. For the design rationale, alternatives considered, and detailed format specifications see docs/analysis/given-when-then-specifications.md.


1. Pick the DSL for your slice​

Event Modeling has four slice patterns; Reventless's component model provides an Aggregate implementation and a DCB implementation of each:

PatternAggregate worldDCB worldGWT DSL
Command (state change)Aggregate + BehaviorStateChangeSliceBehavior_GWT / StateChangeSlice_GWT
View (state projection)ReadModel + Projection.MappingStateViewSliceProjection_GWT / StateViewSlice_GWT
Automation (TODO list)EventMapping (delayed / async)AutomationSliceMapping_GWT (covers the Aggregate side) / Automation_GWT
Translation (anti-corruption)API / handler codeInboundTranslationSlice / OutboundTranslationSliceInboundTranslation_GWT / OutboundTranslation_GWT

Two cross-cutting DSLs round out the surface:

  • Mapping_GWT — cross-pattern automation with any combination of Aggregate or StateChangeSlice source/target.
  • Query_GWT — read model query patterns (indexes, composite keys, resolvers) for both ReadModel and StateViewSlice consumers. This is the only DSL that asserts against config rather than decide / evolve / project.

2. Getting set up​

  1. Add the package as a dev dependency:

    "devDependencies": {
    "@reventlessdev/reventless-gwt": "workspace:*"
    }
  2. Declare it as a ReScript dependency in the package's rescript.json:

    "bs-dev-dependencies": ["@reventlessdev/reventless-gwt"]
  3. In your test file, annotate the top of the file with @@reventless.gwt. The PPX resolves DSL Kind and Spec module from the file path, and emits the right include ReventlessGwt.<Kind>_GWT.Make(<Spec>) + open <Spec> for you.

    File-naming convention: {SpecModule}_GWT.res. One test file per spec module, named after the spec. Matches the CLI's discovery pattern (*_GWT.res.mjs, *GwtTest.res.mjs, *Gwt.res.mjs) and reads as a 1:1 mirror of the source tree:

    SourceTest
    src/StateChange/AddCategory.restests/StateChange/AddCategory_GWT.res
    src/StateView/Categories.restests/StateView/Categories_GWT.res
    src/Projections/CategoriesProjection.restests/Projections/CategoriesProjection_GWT.res

    Kind inference accepts the short folder form (StateChange, StateView, Automation, InboundTranslation, OutboundTranslation), the long singular form (StateChangeSlice), and the plural form (StateChangeSlices), matching the same vocabulary @@reventless.spec uses for production files. The Aggregate-pattern architectural folders Aggregate (mapped to the Behavior DSL with the Behavior_GWT.MakeFromAggregate adapter) and ReadModel (mapped to MultiSourceProjection_GWT.Make) are also recognised. When multiple segments of the path match, the segment closest to the file wins. Spec resolution picks (1) the first top-level module defined in the test file, if any, otherwise (2) the filename stem with the _GWT / GwtTest / Gwt suffix stripped — treated as an external module reference, so no local binding or module Spec = … alias is needed in the consumer pattern.

    Explicit forms (when the path-based inference doesn't fit):

    @@reventless.gwt(AddCategorySlice)                          // explicit Spec (external module reference)
    @@reventless.gwt(CategorySpec, CategoryBehavior) // Behavior DSL (two-arg functor)
    @@reventless.gwt(Categories_Projections.CategoryMapping) // MultiSourceProjection — qualified Mapping payload

    Both payload forms resolve the Spec (and Behavior) names through the compiler, so the modules do not need to be defined locally in the test file — the PPX will prepend the generated open + include at the top of the structure. See .claude/rules/app-developer.md for the full PPX annotation table.

  4. Run your tests:

    pnpm exec reventless-gwt run tests/

    During migration you can also run them under Jest — JestBind routes the same test body into either runner depending on whether the CLI collector is active.


3. Shared vocabulary​

Every DSL uses the same verbs for the same roles:

VerbMeaning
givenEvents([...]) / givenEvent(e)prior events on the entity (sets up evolve)
asCaller(c)name who issues the next whenCmd (Caller.owner(id), Caller.inRoles([...]), Caller.operator, Caller.anonymous)
whenCmd(c)dispatch a command to decide
whenEvent(e) / whenEvents([...])push an event through project / map
whenInput(i)feed external input to a translation slice
whenCollect / whenResolve / whenProcess / whenSweepautomation-loop steps
thenEvent(e) / thenEvents([...])assert decide emitted exactly these events
thenNoEvent / thenNoCommandassert idempotency (nothing produced)
thenError(e)assert decide returned Error(e)
thenRefusedassert the caller was refused before decide, as not owning what the command acts on
thenEventWithError(e, err)decide returned Ok([e]) then later errored
thenState(s) / thenStateWithId(id, s)assert the projected state for one key
thenAllStates([...])assert the full projection store
thenNoState(id)assert no state exists for a key
thenRow(r) / thenRows([...]) / thenRowCount(n)query assertion combinators
thenTodos([...]) / thenResolved(o)automation TODO-list assertions
thenCommand(id, c) / thenCommands([...])commands produced by automation/translation
thenAppendsConditionedOn(events, q) / thenAppendsConditionedOnExactly(events, cond)DCB optimistic-concurrency condition assertions

Deviations from the shared vocabulary are called out per-DSL below.


4. Worked examples — one per DSL​

Each example is a minimal but complete _GWT file that you can copy as a template. All nine are also shipped as runnable worked-example tests in reventless/gwt/tests/.

4.1 Behavior_GWT — Aggregate command slice​

Pure synchronous DSL. Spec module + Behavior module (two-arg functor). In a consumer repo the canonical form is a bare @@reventless.gwt in an Aggregate/ folder — the PPX infers the Behavior DSL and resolves both Category and Category_Behavior, and opens Category for you:

// tests/Category/Aggregate/Category_GWT.res
@@reventless.gwt

describe("Category Behavior", () => {
test("Add on new aggregate produces Added", () =>
givenEvents([])
->whenCmd(Add({name: "Electronics"}))
->thenEvent(Added({name: "Electronics"})))

test("Add on existing aggregate returns CategoryAlreadyExists", () =>
givenEvents([Added({name: "Electronics"})])
->whenCmd(Add({name: "Electronics 2"}))
->thenError(CategoryAlreadyExists))

test("Archive is idempotent", () =>
givenEvents([Added({name: "Electronics"}), Archived])
->whenCmd(Archive)
->thenNoEvent)
})

Real example: examples/online-shop-aggregates/catalog/tests/Category/Aggregate/Category_GWT.res.

Who may act: asCaller and thenRefused​

asCaller names the caller before whenCmd, and the command is checked as production checks it before decide:

  1. its @authorize rule, against the roles the caller holds;
  2. when the slice marks @owner on an event it consumes (or an aggregate on one of its events), the owners the given events record, so it may act only on what the caller owns.
test("a Merchandiser may add a product", () =>
givenEvents([CategoryAdded({categoryId: cat1})])
->asCaller(Caller.inRoles([Merchandiser]))
->whenCmd(AddProduct({productId: p1, name: laptop, categoryId: cat1}))
->thenEvent(ProductAdded({productId: p1, name: laptop, categoryId: cat1}))
)

test("a shopper may not add a product", () =>
givenEvents([CategoryAdded({categoryId: cat1})])
->asCaller(Caller.owner(shopper))
->whenCmd(AddProduct({productId: p1, name: laptop, categoryId: cat1}))
->thenRefused
)

And for ownership:

test("another customer cannot cancel the order", () =>
givenEvents([OrderPlaced({productIds: [p1], customerId: c1})])
->asCaller(Caller.owner(c2))
->whenCmd(CancelOrder({orderId: o1}))
->thenRefused
)

test("an operator may cancel any customer's order", () =>
givenEvents([OrderPlaced({productIds: [p1], customerId: c1})])
->asCaller(Caller.operator)
->whenCmd(CancelOrder({orderId: o1}))
->thenEvent(OrderCancelled({orderId: o1, productIds: [p1]}))
)

The callers:

  • Caller.owner(id): a signed-in person who owns only what records id and holds no restricted role. It takes the scenario's typed id (Caller.owner(c1) with c1: CustomerId.t) or a plain string. Caller.owner(id, ~roles=[...]) holds roles as well.
  • Caller.inRoles([Merchandiser]): a signed-in person holding those roles, who owns nothing the given events record. The roles are typed by the spec's role, the plugin's own Roles.t, so they are written bare, and a role the plugin does not declare does not compile.
  • Caller.operator: a caller the ownership rule does not apply to. In a deployment that is someone in an elevated group, such as an administrator where the administrator group is on the list, or the platform's own traffic. It holds no role, because elevation does not grant a command whose rule the caller fails. See Who is exempt.
  • Caller.anonymous: a caller with no identity, refused by every rule but AllowAnonymous and on anything owned.

The rule is checked against roles, not groups: which group stands for a role is the deployment's fact, so a scenario does not depend on it. thenRefused passes for either refusal, since both are Forbidden to a client; let the title say which rule refused.

A scenario without asCaller names no caller, which the handler reads as the platform acting for itself, so neither rule refuses it. Any other then* after a refusal fails, because decide never ran. The lifecycle check leaves thenRefused scenarios out: who may act says nothing about which states a command is legal in.

4.2 StateChangeSlice_GWT — DCB command slice​

Same vocabulary as Behavior_GWT, plus DCB-specific thenAppendsConditionedOn / thenAppendsConditionedOnExactly for the optimistic-concurrency query.

@@reventless.gwt

module AddCategorySlice = {
let name = "AddCategory"

type state = {exists: bool, archived: bool}
let initialState = {exists: false, archived: false}

@schema type consumedEvent = CategoryAdded | CategoryArchived

let evolve = (state, event) =>
switch event {
| CategoryAdded => {exists: true, archived: false}
| CategoryArchived => {...state, archived: true}
}

@schema
type command =
AddCategory({categoryId: @s.matches(Reventless.DcbTag.string) string, name: string})

@schema type error = CategoryAlreadyExists

@schema
type event =
CategoryAdded({categoryId: @s.matches(Reventless.DcbTag.string) string, name: string})

let decide = (state, command) =>
switch command {
| AddCategory({categoryId, name}) =>
state.exists ? Error(CategoryAlreadyExists) : Ok([CategoryAdded({categoryId, name})])
}
}

describe("AddCategory StateChangeSlice", () => {
test("empty event log produces CategoryAdded", () =>
givenEvents([])
->whenCmd(AddCategory({categoryId: "c1", name: "Electronics"}))
->thenEvent(CategoryAdded({categoryId: "c1", name: "Electronics"})))

test("existing category returns CategoryAlreadyExists", () =>
givenEvents([CategoryAdded])
->whenCmd(AddCategory({categoryId: "c1", name: "X"}))
->thenError(CategoryAlreadyExists))

test("append condition is single-entity query", () =>
givenEvents([])
->whenCmd(AddCategory({categoryId: "c1", name: "Electronics"}))
->thenAppendsConditionedOn([
{
eventTypes: ["CategoryAdded", "CategoryArchived"],
tags: [{key: "categoryId", value: "c1"}],
},
]))
})

Runnable copy: reventless/gwt/tests/StateChangeSliceGwtTest.res.

Important: every @s.matches(Reventless.DcbTag.string) annotation on the command's ID fields is what enables the implicit AppendConditionMismatch check. Forgetting it causes the GWT's DCB-query derivation to produce an empty-tags clause; the next then* surfaces this as AppendConditionMismatch, pointing at the command schema. See §9 Output format reference.

4.3 Projection_GWT — ReadModel projection​

Async DSL with a synthetic in-memory store. In a consumer repo a bare @@reventless.gwt in a ReadModel/ folder infers the multi-source projection DSL; the explicit form names the Mapping.Make module from the projections file:

// tests/Category/ReadModel/Categories_GWT.res
@@reventless.gwt(Categories_Projections.CategoryMapping)

describe("Categories ReadModel ← Category", () => {
test("Added sets the initial read model state", () =>
givenEvents([])
->whenEvent(Category.Added({name: "Electronics"}))
->thenState({Categories.name: "Electronics", archived: false}))

test("Renamed updates the name", () =>
givenEvents([Category.Added({name: "Electronics"})])
->whenEvent(Category.Renamed({name: "Consumer Electronics"}))
->thenState({Categories.name: "Consumer Electronics", archived: false}))
})

Real example: examples/online-shop-aggregates/catalog/tests/Category/ReadModel/Categories_GWT.res.

4.4 StateViewSlice_GWT — DCB state-view slice​

Same projection shape as Projection_GWT but the spec owns both the consumed-event schema and the project function (no separate Mapping module).

@@reventless.gwt

open Reventless.Projection

module CategoriesViewSpec = {
let name = "CategoriesView"

@schema type state = {categoryId: string, name: string, archived: bool}

@schema
type consumedEvent =
| CategoryAdded({categoryId: string, name: string})
| CategoryRenamed({categoryId: string, name: string})
| CategoryArchived({categoryId: string})

let project = ({event}) =>
switch event {
| CategoryAdded({categoryId, name}) => [
Set(categoryId, {categoryId, name, archived: false}),
]
| CategoryRenamed({categoryId, name}) => [
Update(categoryId, state => {...state, name}),
]
| CategoryArchived({categoryId}) => [
Update(categoryId, state => {...state, archived: true}),
]
}

let subIdConfig = None
}

describe("CategoriesView StateViewSlice", () => {
test("CategoryAdded projects into a new row", () =>
givenEvents([])
->whenEvent(CategoryAdded({categoryId: "c1", name: "Electronics"}))
->thenStateWithId(
"c1",
{categoryId: "c1", name: "Electronics", archived: false},
))
})

Runnable copy: reventless/gwt/tests/StateViewSliceGwtTest.res.

4.5 Query_GWT — ReadModel + StateViewSlice queries​

The only DSL whose assertions point at config (indexes, composite keys, resolvers) rather than decide / project. Works against either a ReadModel (FromReadModel) or a StateViewSlice (FromStateViewSlice) — they share the same config and subIdConfig surface at runtime.

module CategoriesQuery =
Query_GWT.Make(Query_GWT.FromReadModel(CategoriesReadModel))

CategoriesQuery.describe("Categories ReadModel queries", () => {
CategoriesQuery.test("primary-id lookup returns the row", () =>
CategoriesQuery.givenStore([
("c1", {categoryId: "c1", name: "Electronics", archived: false}),
])
->CategoriesQuery.whenQueryById("c1")
->CategoriesQuery.thenRow(Some({categoryId: "c1", name: "Electronics", archived: false})))

CategoriesQuery.test("by-name (GSI) returns matching rows", () =>
CategoriesQuery.givenStore([
("c1", {categoryId: "c1", name: "Electronics", archived: false}),
("c2", {categoryId: "c2", name: "Books", archived: true}),
])
->CategoriesQuery.whenQuery({by: "name", value: "Electronics", index: "byName"})
->CategoriesQuery.thenRows([{categoryId: "c1", name: "Electronics", archived: false}]))
})

whenQuery fails with QueryRowsMismatch when index is named but the referenced entry is missing from Spec.config.indexes. Runnable copy: reventless/gwt/tests/QueryGwtTest.res.

4.6 Mapping_GWT — cross-pattern automation​

Generalised cross-pattern automation. Source and Target can each be an Aggregate+Behavior pair (via FromBehavior) or a StateChangeSlice (via FromStateChangeSlice), giving all four Aggregate/DCB combinations with the same vocabulary.

module CategorySource   = Mapping_GWT.FromBehavior(CategorySpec, CategoryBehavior)
module ProductTarget = Mapping_GWT.FromBehavior(ProductSpec, ProductBehavior)

module CatalogMapping = {
module Source = CategorySource
module Target = ProductTarget

let map = (_sourceId, event: CategorySpec.event, _q) =>
switch event {
| CategoryAdded({categoryId, name}) => [
Reventless.EventMapping.Publish(
("prod-for-" ++ categoryId)->ProductSpec.Id.makeFromString,
ProductSpec.MirrorCategory({
productId: "prod-for-" ++ categoryId, categoryId, name,
}),
),
]
}
}

module CatalogGwt = Mapping_GWT.Make(CatalogMapping)

CatalogGwt.describe("Category → Product (Aggr → Aggr)", () =>
CatalogGwt.test("AddCategory produces ProductCategoryMirrored", () =>
CatalogGwt.givenSourceEvents([])
->CatalogGwt.andTargetEvents([])
->CatalogGwt.whenSourceCmd("c1", AddCategory({categoryId: "c1", name: "Books"}))
->CatalogGwt.thenTargetEvent(
"prod-for-c1",
ProductCategoryMirrored({productId: "prod-for-c1", categoryId: "c1", name: "Books"}),
))
)

thenTargetError, thenSourceError, thenTargetEventsWithError are also available for the error combinations. Swap one or both adapters to FromStateChangeSlice for the DCB combinations. Runnable copies of all four Aggregate/DCB combinations live in reventless/gwt/tests/MappingGwtTest.res.

4.7 Automation_GWT — DCB automation​

An automation test is written against the slice as it is in src/: its spec (AutoShipOrder.res) and its body (AutoShipOrder_Automation.res, with the per-source mappings, process and onExhausted). With no module of its own in the test file, @@reventless.gwt opens both and includes Automation_GWT.FromSlice(AutoShipOrder, AutoShipOrder_Automation).

A mapping's event type is hidden inside mappings, so the per-source verbs come from Mapping(M). A single-source slice includes it; a multi-source slice names one module per source (module Orders = Mapping(FromOrders)) and qualifies the calls.

// tests/Order/Automation/AutoShipOrder_GWT.res
@@reventless.gwt

include Mapping(FromOrderingDcb)

describe("AutoShipOrder AutomationSlice", () => {
test("collect: an Express OrderPlaced creates a pending TODO", () =>
givenEvent(OrderPlaced({orderId: o1, shippingMethod: Express}))
->whenCollect
->thenTodos([("o1", {orderId: o1})]))

test("resolve: OrderShipped marks the TODO done", () =>
givenEvent(OrderShipped({orderId: o1}))->whenResolve->thenResolved(Some("o1")))

test("process: pending TODO emits ShipOrder", () =>
givenTodo("o1", {orderId: o1})->whenProcess->thenCommand("o1", ShipOrder({orderId: o1})))

test("exhausted: nothing is said", () =>
givenTodo("o1", {orderId: o1})->whenExhausted->thenNoCommand)

test("sweep: an Express order ships, a shipped one is closed", () =>
givenEvents([
event(OrderPlaced({orderId: o1, shippingMethod: Express})),
event(OrderPlaced({orderId: o2, shippingMethod: Express})),
event(OrderShipped({orderId: o2})),
])
->whenSweep
->thenCommands([("o1", ShipOrder({orderId: o1}))]))
})
VerbDoes
givenEvent(e) → whenCollect / whenResolveone mapping's collect / resolve. whenCollect takes ~sourceId (an aggregate source's entity id) and ~context, both defaulted
thenTodos(items), thenResolved(id)the rows collected, the id resolved
givenTodo(id, item) → whenProcess / whenExhaustedprocess, or onExhausted once the budget is spent
thenCommand(id, cmd), thenNoCommandwhat either one publishes
givenEvents([event(e), …]) → whenSweepevery event routed by its source's name and decoded, exactly as the runtime does; event(e, ~sourceId) wraps a typed event
thenCommands(pairs), andThenEvents([…]), thenScenarioTodos(items)the commands the sweep issues; later events that resolve rows; the rows still open

The sweep applies events in order, as the runtime's to-do list does: the first writer of an id wins, and a resolve completes only a row that already exists. thenScenarioTodos compares the whole open list, in that order — a sweep that opens an extra row fails it.

Runnable copy, with two sources: reventless/gwt/tests/AutomationFromSliceGwtTest.res.

4.8 InboundTranslation_GWT — external → internal translation​

No given clause (translation is pure over its input). With no module of its own, the test includes InboundTranslation_GWT.FromSlice(<Spec>, <Spec>_Translation).

// tests/Product/InboundTranslation/ImportProduct_GWT.res
@@reventless.gwt

describe("ImportProduct InboundTranslationSlice", () => {
test("a USD payload translates to AddProduct", () =>
whenInput({sku, title: laptop, desc: highEnd, unitPrice: 99999, currency: usd})
->thenCommand("p-1", AddProduct({productId: pid("p-1"), name: laptop, description: highEnd, price: 999.99})))

test("a payload missing its title is refused before translation", () =>
whenReceived(JSON.parseOrThrow(`{"sku": "SKU-1"}`))->thenRefusedInput("title"))

test("an empty SKU is not understood", () =>
whenInput({...valid, sku: ""})->thenNotUnderstood("SKU is required"))
})
VerbDoes
whenInput(record)the slice's translate on a typed input
whenReceived(json)input as it arrives: decoded through externalInputSchema first
thenCommands(pairs), thenCommand(id, cmd), thenNoCommandthe commands; each must also encode and decode through commandSchema
thenNotUnderstood(msg)translate returned Error(msg). thenTranslateError is the older name
thenRefusedInput(reason)the input did not decode; passes when the decoder's message contains reason

4.9 OutboundTranslation_GWT — internal → external translation​

A collect pipeline (same shape as an automation's) and a translate pipeline that runs the slice's own translate against recording capability fakes. With no module of its own, the test includes OutboundTranslation_GWT.FromSlice(<Spec>, <Spec>_Translation).

// tests/Customer/OutboundTranslation/GeocodeCustomerAddress_GWT.res
@@reventless.gwt

let geocoder = answer => fakes(~geocode=async (~text as _) => answer, ())

describe("GeocodeCustomerAddress OutboundTranslationSlice", () => {
testSync("collect keys by entity and address", () =>
givenEvent(AddressUpdated({address: viennaAddress}))
->whenCollect(~sourceId="cust-1")
->thenTodos([("cust-1:Stephansplatz 1, Vienna", {customerId: cust1, address: viennaAddress})]))

test("the geocoder is asked for the address as written", () =>
givenTodo("cust-1:Stephansplatz 1, Vienna", {customerId: cust1, address: viennaAddress})
->givenCapabilities(geocoder(Ok([])))
->whenTranslated
->thenSent([Geocoded({text: viennaAddress})]))

test("a geocoder that is down leaves the row to be retried", () =>
givenTodo("cust-1:Stephansplatz 1, Vienna", {customerId: cust1, address: viennaAddress})
->givenCapabilities(geocoder(Error(Unavailable("timeout"))))
->whenTranslated
->thenTodoStatus("cust-1:Stephansplatz 1, Vienna", #Failed))
})
VerbDoes
givenEvent(e) → whenCollect(~sourceId=?) → thenTodos(items)collect
givenTodo(id, item), givenCapabilities(fakes(...))the row, and the capabilities translate is handed. Unscripted capabilities answer with a plain success
whenTranslatedone attempt of the real translate; a throw is a failed attempt
whenTranslateMocked(mock)one attempt answered by mock — for a slice that calls a service the framework does not broker
whenTranslateRetrying(~maxRetries=?, mock)attempts until one succeeds or maxRetries (the Spec's by default) have failed
whenExhausted(~lastError=?)what onExhausted publishes once the budget is spent
thenCommand(id, cmd), thenNoCommandthe command published back, if any
thenSent(calls), thenNothingSentevery capability call, in order (Sent, Geocoded, TokenDrawn, …; see Capabilities_Fake.call)
thenTodoStatus(id, s)#Completed, #Failed (to be retried) or #Abandoned, counted as the runtime counts: maxRetries failed attempts abandon the row. #Pending is the older name for #Failed
thenRetryRecorded(n)the row's retryCount: attempts that failed

Runnable copy: reventless/gwt/tests/OutboundFromSliceGwtTest.res.

The flat form. A test file that declares a module X = { … } keeps today's <Kind>_GWT.Make(X), which takes an adapter module that flattens the slice. It is deprecated and goes one release after the examples have moved; a module alias (module Rule = …) or a functor application (module Orders = Mapping(…)) does not count as one.

In a flow. Flow_GWT.AutomationSlice(Spec, Automation), Flow_GWT.OutboundSlice(Spec, Translation) and Flow_GWT.InboundSlice(Spec, Translation) take the same two modules, with whenReacts / thenIssuesCommand(s), thenOutbound(items) and whenReceived(json) / thenIssuesCommand(s).

4.10 Testing external slice modules (the consumer pattern)​

The worked examples in §§ 4.1–4.6 are inline — the spec module is defined in the same file as the tests. This is the pattern used inside the reventless-gwt package itself, where each test file documents one DSL.

In downstream consumer repos, production slice modules already live in src/, and re-inlining them in tests would be a stale copy. The canonical pattern there is to reference the production module directly:

// tests/StateChange/AddCategory_GWT.res
@@reventless.gwt

// No payload. No local module. No alias.
//
// Kind inferred from the folder segment "StateChange" → StateChangeSlice.
// Spec inferred from the filename stem "AddCategory" (with `_GWT` stripped)
// and treated as an external module reference.
// `open AddCategory` is injected by the PPX, so the command/event variants
// and state fields are reachable unqualified in the test bodies below.

describe("AddCategory StateChangeSlice", () => {
test("empty event log produces CategoryAdded", () =>
givenEvents([])
->whenCmd(AddCategory({categoryId: "c1", name: "Electronics"}))
->thenEvent(CategoryAdded({categoryId: "c1", name: "Electronics"})))

test("existing category returns CategoryAlreadyExists", () =>
givenEvents([CategoryAdded])
->whenCmd(AddCategory({categoryId: "c1", name: "X"}))
->thenError(CategoryAlreadyExists))
})

Conventions to follow:

  • One _GWT.res file per spec module, named {SpecModule}_GWT.res. tests/ mirrors src/ 1:1. If src/StateChange/AddCategory.res exists, its tests live at tests/StateChange/AddCategory_GWT.res.
  • Bare @@reventless.gwt — no payload. Kind comes from the folder segment (StateChange → StateChangeSlice) using the same vocabulary @@reventless.spec uses for source files. Spec comes from the filename stem with _GWT stripped. Every folder segment in the full path is considered, not just the immediate parent — tests can live at any nesting depth. When the path contains multiple slice-base segments (rare), the closer-to-file one wins.
  • No module aliases in test files. If you do need to point at a Spec whose name doesn't match the filename, @@reventless.gwt(SpecModule) accepts the external module directly; the PPX emits the open + include for you at the top of the file.
  • No explicit open. The PPX emits one for you so the spec's variants, fields, and initialState read unqualified.

The same pattern applies to every DSL. Behavior is the one exception to the zero-payload form: the two-arg functor needs both modules named — @@reventless.gwt(CategorySpec, CategoryBehavior). Automations and translations pair the spec with its body file by name — <Spec>_Automation or <Spec>_Translation — so they keep the bare form (§§ 4.7–4.9).

4.11 Companion fixtures module (<Stem>_Fixtures.res)​

For a shared value, reach for an example file (§ 4.12) instead. The example plugins use no fixtures modules at all. Fixtures remain the right tool for what an example is not: a prepared command or event, an expected state record, anything a single file finds repetitive, and anything you want auto-opened without writing the open.

Fixture-heavy suites — repeated identity strings, command/event payloads, expected state records — benefit from extracting shared values to a sibling module. When a file named <Stem>_Fixtures.res sits next to <Stem>_GWT.res, @@reventless.gwt detects it on disk and auto-opens it in the test body, after the Spec open:

// tests/StateChange/AddCategory_Fixtures.res
open AddCategory

let addCategoryElectronics = AddCategory({categoryId: "c1", name: "Electronics"})
let electronicsCategoryAdded = CategoryAdded({categoryId: "c1", name: "Electronics"})
// tests/StateChange/AddCategory_GWT.res
@@reventless.gwt

describe("AddCategory StateChangeSlice", () => {
test("empty event log produces CategoryAdded", () =>
givenEvents([])
->whenCmd(addCategoryElectronics)
->thenEvent((electronicsCategoryAdded :> event))
)
})

No manual open AddCategory_Fixtures in the test — the PPX emits it. Emission order is open <Spec>; open <Stem>_Fixtures; include <Kind>_GWT.Make(<Spec>), so fixture bindings intentionally shadow same-named Spec bindings when you want them to.

Conventions:

  • Filename is fixed. The PPX only looks for the literal name <Stem>_Fixtures.res, where <Stem> is the GWT filename stem with _GWT / GwtTest / Gwt stripped. Other companion shapes (_Helpers, _Builders) are not auto-opened.
  • Manual opens are deduped. If a test writes open <Stem>_Fixtures explicitly, the PPX skips the duplicate injection — the source stays valid.
  • Shared primitives via include. A fixtures module can include a repo-wide primitives module (e.g. TestFixtures.res) so those primitives flow through the auto-open transitively, without each GWT test needing its own manual open.
  • Explicit-payload and Behavior DSL. The companion rule keys off the filename stem, not the Spec name. @@reventless.gwt(OtherSpec) on AddCategory_GWT.res still auto-opens AddCategory_Fixtures.res if present. Likewise for @@reventless.gwt(Spec, Behavior) pairs.

Not auto-opened: spec-adjacent types modules (e.g. DeploymentTypes, shared-variant modules beyond the Spec). Those still need an explicit open in the test body.

4.12 Example files (<Plugin>_Examples.res, <Slice>_Examples.res)​

This is what the example plugins use, and what to reach for first. A fixtures module (§ 4.11) holds whatever a file finds repetitive — a whole command, an expected state record, a helper. An example is narrower and more useful: a single named value that more than one test means.

From examples/online-shop-hybrid/ordering — the plugin's shared values, grouped by type (extract):

// tests/Ordering_Examples.res
@@reventless.examples

let p1: CatalogSpec.ProductId.t = CatalogSpec.ProductId.makeFromString("p1")
let p2: CatalogSpec.ProductId.t = CatalogSpec.ProductId.makeFromString("p2")

let c1: CustomerId.t = CustomerId.makeFromString("c1")

let o1: OrderId.t = OrderId.makeFromString("o1")

let fathomDock: string = "Fathom Dock"

let dockPrice: Reventless.Money.t = Reventless.Money.make(
~amount=2500.0,
~currency=Reventless.Currency.EUR,
)

The slice's own record type lives beside its test, not in the plugin file:

// tests/Order/StateChange/PlaceOrder_Examples.res
@@reventless.examples

open PlaceOrder
open Ordering_Examples

// The line eleven tests place: one Fathom Dock at its shelf price.
let dockLine: orderLine = {
productId: p1,
name: fathomDock,
quantity: 1,
unitPrice: dockPrice,
lineTotal: dockPrice,
}
// tests/Order/StateChange/PlaceOrder_GWT.res
@@reventless.gwt

open Ordering_Examples
open PlaceOrder_Examples

describe("PlaceOrder StateChangeSlice", () => {
// scenario-id: 7e494f69-e2bf-4727-8c51-08317ca42e0d
test("placement succeeds when products are available", () =>
givenEvents([CatalogProductSynced({productId: p1, name: fathomDock, price: dockPrice})])
->whenCmd(
PlaceOrder({
orderId: o1,
customerId: c1,
lineItems: [{productId: p1, quantity: 1}],
shippingMethod: Standard,
}),
)
->thenEvent(
OrderPlaced({
orderId: o1,
customerId: c1,
productIds: [p1],
lines: [dockLine],
total: dockPrice,
shippingMethod: Standard,
firstProductName: fathomDock,
}),
)
)
})

Why it is worth the extra file rather than a literal in each test:

  • The same thing is spelled one way. Before this was made uniform, pid("p1") appeared in nearly every file, each defining its own alias; cid meant CategoryId in one plugin and CustomerId in another; and eur meant major units in nine files and minor units in two, so eur(2500.0) was €2500 in one test and €25 in another.
  • The authoring tooling can read it. A step written from named values comes back as a name the scenario form shows as a chip and offers in a field menu. A step built from a local helper call comes back as ReScript text with nothing to pick.
  • A price changes in one place. Every test that meant that price follows.

Conventions:

  • Where a value lives is decided by its type. Ids, framework values (Reventless.Money.t, Reventless.DateTime.t) and other plugins' types go in tests/<Plugin>_Examples.res. A slice's own records and variants go in <Slice>_Examples.res beside the GWT file — as Order/StateChange/PlaceOrder_Examples.res holds dockLine, the orderLine eleven tests place.
  • Two tests or more. A value only one test uses stays inline, written the way codegen writes it. There is nothing to connect.
  • Name it for what it means, not for its field. laptopPrice, not eur999_99; mainStreet, not address. An id is named after its value, so "p1" becomes p1.
  • Grouped by type, sorted by name within a group. The tooling maintains this when it adds one, so a hand-added example goes in its group too.
  • Opened explicitly. Unlike a fixtures module, an example file is not auto-opened — the test writes open <Plugin>_Examples. Both the plugin file and a slice file can be open at once.
  • Money is written in minor units through Reventless.Money.make. That is the one form; per-file eur/usd helpers are what this replaced.
  • // scenario-id: marks each test so the authoring tooling can address it by id and rewrite that test alone. The tooling adds one when it first saves a test.

pnpm run check:examples enforces the part that can be checked without types: a GWT file keeps no value helper it could retire. An id alias whose every call passes a name a let could bind, or a money writer whose every call writes its amount out, is a finding. A helper stays when a call passes a variable, or when an id's literal is not a name — pid("p-1") has to say its value, because no let can bind p-1.


5. The Outcome algebra​

Every then* combinator returns Outcome.outcome (an alias for result<unit, Outcome.mismatch>). The runner, the human formatter, the CI TAP stream, the AI-generation loop, and the VS Code extension all consume the same value — they only differ in how they render it.

mismatch is a closed sum of nine variants:

VariantThrown byDefault locus hint
EventsMismatchthenEvent(s) when decide / map events differ{slice}.decide
ErrorMismatchthenError when decide returned Ok(_) or a different error variant{slice}.decide
StateMismatchthenState(WithId) / thenAllStates{slice}.evolve; {slice}.project for a view or read model
NoEventExpectedthenNoEvent / thenNoCommand when something was produced{slice}.decide
TodoMismatchthenTodos / thenScenarioTodos{slice}.collect / {slice}.resolve
AppendConditionMismatchimplicit DCB check + thenAppendsConditionedOn*{slice}.commandSchema — usually a missing @s.matches(DcbTag.string)
TranslateErrorthenTranslateError{slice}.translate
QueryRowsMismatchthenRow(s) / thenRowCount / missing-index check{slice}.config
Throwany uncaught exception inside the DSL's pipeline{slice}

Every failure is paired with a Hint record {locus, branch, message}. Hints ship inside the JSON/VS Code output so downstream tools can route a fix without re-deriving the mapping. The canonical table lives in reventless-gwt/src/Hint.res.


6. The CLI runner​

reventless-gwt run      [--format=<fmt>] [--filter=<id>] [--stream] [--watch] [path...]
reventless-gwt discover [--format=vscode] [path...]
reventless-gwt watch [--format=<fmt>] [--filter=<id>] [path...]

Exit code is 1 if any test failed, 0 otherwise. Path arguments default to tests/ — discovery finds files matching *_GWT.res.mjs, *GwtTest.res.mjs, or *Gwt.res.mjs under the given roots, skipping node_modules, .git, lib, and dist.

FlagEffect
--format=<fmt>Pick one of human (default), json, tap, junit, vscode
--filter=<id>Restrict execution to tests whose id contains <id> (repeatable)
--streamNDJSON streaming variant of json / vscode (default emits a single envelope)
--watchRe-run on .res.mjs changes in the discovered roots
--schema-version=<v>Pin the JSON schema version for stable AI prompts
--help / -hShow help and exit

SIGINT / SIGTERM cancel in-flight runs cleanly: the in-flight test is marked Skip{reason: "cancelled"} and the runner exits with the failure count so far.


7. Output formats​

FormatShapeTypical consumer
humanANSI-coloured terminal output (auto-disabled on non-TTY). Variants rendered in ReScript syntax (Added({name: "X"})) instead of {TAG: "Added", _0: ...}.Local development
jsonSingle envelope by default, NDJSON with --stream. schemaVersion: "1.0.0". Every value is dual-rendered as {type, payload, rendered}. Includes precomputed hint and fieldDiff arrays.AI generation loop, structured CI pipelines
tapTAP 14 with 1..N plan and YAML diagnostic blocks on failure.tap-spec, actions/test-reporter, generic TAP tools
junit<testsuites> / <testsuite> / <testcase> with <failure> bodies.CI dashboards, Jenkins / GitLab / Azure reporters
vscodeNDJSON event stream — discoverStart / item / discoverEnd for the tree; runStart / testStart / testPass / testFail / testSkip / runEnd for execution. Field names map 1:1 onto VS Code's TestItem / TestMessage / TestRun API.reventless-vscode extension

The JSON envelope documents its own shape via schemaVersion; consumers downgrade by passing --schema-version=<v> when a field-level break ships.


8. Editor integration​

reventless-gwt discover and reventless-gwt run both accept --format=vscode, which emits an NDJSON event stream whose field names map 1:1 onto VS Code's TestItem / TestMessage / TestRun API — discoverStart / item / discoverEnd for the tree, and runStart / testStart / testPass / testFail / testSkip / runEnd for execution. Failures carry a source location, so an editor integration can jump straight to the slice that failed.

That format exists so an editor extension can drive the runner without parsing human-readable output. Any editor that can spawn a process and read NDJSON can use it.

9. Output format reference​

Example failure for an AppendConditionMismatch (most common DCB pitfall):

Human:

✗ AddCategory StateChangeSlice > empty event log produces CategoryAdded

AppendConditionMismatch:
expected: {"query":[{"eventTypes":["CategoryAdded","CategoryArchived"],"tags":[{"key":"categoryId","value":"c1"}]}]}
actual: {"query":[{"eventTypes":["CategoryAdded","CategoryArchived"],"tags":[]}]}

hint: DCB optimistic-concurrency condition drift — likely a missing
`@s.matches(DcbTag.string)` annotation on a command field, …
Look at AddCategory.commandSchema.

JSON (single envelope, abridged):

{
"schemaVersion": "1.0.0",
"summary": { "passed": 0, "failed": 1, "skipped": 0 },
"files": [{
"path": ".../AddCategoryGwtTest.res.mjs",
"tests": [{
"id": "AddCategoryGwtTest.res.mjs::AddCategory StateChangeSlice::empty event log …",
"status": "failed",
"mismatch": {
"kind": "AppendConditionMismatch",
"expected": { "query": [...] },
"actual": { "query": [...] }
},
"hint": {
"locus": "AddCategory.commandSchema",
"branch": null,
"message": "DCB optimistic-concurrency condition drift — likely a missing `@s.matches(DcbTag.string)` …"
}
}]
}]
}

Full field reference lives in docs/analysis/given-when-then-specifications.md §3.3.


10. Test conventions​

  • Example plugins ship *_GWT.res files and their example files. In the example plugins (examples/online-shop-aggregates/, online-shop-dcb/, online-shop-hybrid/) the tests/ tree contains only *_GWT.res files and the *_Examples.res they share values through (§ 4.12) — no E2E, no ad-hoc *BehaviorTest.res / *DecisionTest.res / *ProjectionTest.res. Tests mirror src/ 1:1 (so the PPX folder-segment heuristic resolves the kind). Ship one *_GWT.res per Aggregate / StateChangeSlice / StateViewSlice / StateViewSliceStream / ReadModel / AutomationSlice / InboundTranslationSlice / OutboundTranslationSlice. AWS adapter packages and *-spec packages keep zero tests.
  • Cross-plugin E2E tests live in framework or app code that genuinely needs them. They dispatch real commands through the in-memory bus and assert on emitted events; they're integration tests, not GWTs, and the runner doesn't consume them.
  • Prefer @@reventless.gwt at the top of a test file and let the PPX infer the kind; the explicit include <Kind>_GWT.Make(<Spec>) form also works. See .claude/rules/app-developer.md for the full attribute form.
  • Production-slice tests reference the real module via @@reventless.gwt(ProductionModule) (see § 4.10) rather than an inline copy. The inline style is reserved for tests that document a DSL pattern itself.

Writing scenarios that stay useful​

  • Name the scenario, not the test. "archived category still rejects a new AddCategory" tells you what broke; "test3" makes you read the body.
  • One behaviour per scenario. Chaining a second command onto the end makes a failure ambiguous — you learn that something in the chain broke, not which.
  • Assert the error cases too. A decide is half rejection logic; a suite that only covers the happy path leaves the half that protects your invariants untested.
  • Cover idempotency wherever the command has it. At-least-once delivery means a repeated command is normal traffic, so thenNoEvent on the second application is a real assertion, not a formality.
  • Name a value two tests share in the plugin's example file (see § 4.12) rather than repeating the literal — dockPrice, not the amount written out in each test. For repeated setup rather than a value, a companion fixtures module (§ 4.11) is auto-opened, so the scenario body stays the interesting part.
  • Reach for edge cases the domain actually has: empty history, an entity in a terminal state, an optional field absent rather than empty.