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:
| Pattern | Aggregate world | DCB world | GWT DSL |
|---|---|---|---|
| Command (state change) | Aggregate + Behavior | StateChangeSlice | Behavior_GWT / StateChangeSlice_GWT |
| View (state projection) | ReadModel + Projection.Mapping | StateViewSlice | Projection_GWT / StateViewSlice_GWT |
| Automation (TODO list) | EventMapping (delayed / async) | AutomationSlice | Mapping_GWT (covers the Aggregate side) / Automation_GWT |
| Translation (anti-corruption) | API / handler code | InboundTranslationSlice / OutboundTranslationSlice | InboundTranslation_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 againstconfigrather thandecide/evolve/project.
2. Getting set up
-
Add the package as a dev dependency:
"devDependencies": {
"@reventlessdev/reventless-gwt": "workspace:*"
} -
Declare it as a ReScript dependency in the package's
rescript.json:"bs-dev-dependencies": ["@reventlessdev/reventless-gwt"] -
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 rightinclude 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:Source Test src/StateChange/AddCategory.restests/StateChange/AddCategory_GWT.ressrc/StateView/Categories.restests/StateView/Categories_GWT.ressrc/Projections/CategoriesProjection.restests/Projections/CategoriesProjection_GWT.resKind 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.specuses for production files. The Aggregate-pattern architectural foldersAggregate(mapped to theBehaviorDSL with theBehavior_GWT.MakeFromAggregateadapter) andReadModel(mapped toMultiSourceProjection_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/Gwtsuffix stripped — treated as an external module reference, so no local binding ormodule 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 payloadBoth 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+includeat the top of the structure. See.claude/rules/app-developer.mdfor the full PPX annotation table. -
Run your tests:
pnpm exec reventless-gwt run tests/During migration you can also run them under Jest —
JestBindroutes 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:
| Verb | Meaning |
|---|---|
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 / whenSweep | automation-loop steps |
thenEvent(e) / thenEvents([...]) | assert decide emitted exactly these events |
thenNoEvent / thenNoCommand | assert idempotency (nothing produced) |
thenError(e) | assert decide returned Error(e) |
thenRefused | assert 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:
- its
@authorizerule, against the roles the caller holds; - when the slice marks
@owneron 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 recordsidand holds no restricted role. It takes the scenario's typed id (Caller.owner(c1)withc1: 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'srole, the plugin's ownRoles.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 butAllowAnonymousand 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}))]))
})
| Verb | Does |
|---|---|
givenEvent(e) → whenCollect / whenResolve | one 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 / whenExhausted | process, or onExhausted once the budget is spent |
thenCommand(id, cmd), thenNoCommand | what either one publishes |
givenEvents([event(e), …]) → whenSweep | every 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"))
})
| Verb | Does |
|---|---|
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), thenNoCommand | the 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))
})
| Verb | Does |
|---|---|
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 |
whenTranslated | one 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), thenNoCommand | the command published back, if any |
thenSent(calls), thenNothingSent | every 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.resfile per spec module, named{SpecModule}_GWT.res.tests/mirrorssrc/1:1. Ifsrc/StateChange/AddCategory.resexists, its tests live attests/StateChange/AddCategory_GWT.res. - Bare
@@reventless.gwt— no payload. Kind comes from the folder segment (StateChange→StateChangeSlice) using the same vocabulary@@reventless.specuses for source files. Spec comes from the filename stem with_GWTstripped. 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 theopen+includefor you at the top of the file. - No explicit
open. The PPX emits one for you so the spec's variants, fields, andinitialStateread 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/Gwtstripped. Other companion shapes (_Helpers,_Builders) are not auto-opened. - Manual opens are deduped. If a test writes
open <Stem>_Fixturesexplicitly, the PPX skips the duplicate injection — the source stays valid. - Shared primitives via
include. A fixtures module canincludea 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)onAddCategory_GWT.resstill auto-opensAddCategory_Fixtures.resif 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;cidmeantCategoryIdin one plugin andCustomerIdin another; andeurmeant major units in nine files and minor units in two, soeur(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 intests/<Plugin>_Examples.res. A slice's own records and variants go in<Slice>_Examples.resbeside the GWT file — asOrder/StateChange/PlaceOrder_Examples.resholdsdockLine, theorderLineeleven 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, noteur999_99;mainStreet, notaddress. An id is named after its value, so"p1"becomesp1. - 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-fileeur/usdhelpers 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:
| Variant | Thrown by | Default locus hint |
|---|---|---|
EventsMismatch | thenEvent(s) when decide / map events differ | {slice}.decide |
ErrorMismatch | thenError when decide returned Ok(_) or a different error variant | {slice}.decide |
StateMismatch | thenState(WithId) / thenAllStates | {slice}.evolve; {slice}.project for a view or read model |
NoEventExpected | thenNoEvent / thenNoCommand when something was produced | {slice}.decide |
TodoMismatch | thenTodos / thenScenarioTodos | {slice}.collect / {slice}.resolve |
AppendConditionMismatch | implicit DCB check + thenAppendsConditionedOn* | {slice}.commandSchema — usually a missing @s.matches(DcbTag.string) |
TranslateError | thenTranslateError | {slice}.translate |
QueryRowsMismatch | thenRow(s) / thenRowCount / missing-index check | {slice}.config |
Throw | any 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.
| Flag | Effect |
|---|---|
--format=<fmt> | Pick one of human (default), json, tap, junit, vscode |
--filter=<id> | Restrict execution to tests whose id contains <id> (repeatable) |
--stream | NDJSON streaming variant of json / vscode (default emits a single envelope) |
--watch | Re-run on .res.mjs changes in the discovered roots |
--schema-version=<v> | Pin the JSON schema version for stable AI prompts |
--help / -h | Show 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
| Format | Shape | Typical consumer |
|---|---|---|
human | ANSI-coloured terminal output (auto-disabled on non-TTY). Variants rendered in ReScript syntax (Added({name: "X"})) instead of {TAG: "Added", _0: ...}. | Local development |
json | Single 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 |
tap | TAP 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 |
vscode | NDJSON 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.resfiles and their example files. In the example plugins (examples/online-shop-aggregates/,online-shop-dcb/,online-shop-hybrid/) thetests/tree contains only*_GWT.resfiles and the*_Examples.resthey share values through (§ 4.12) — noE2E, no ad-hoc*BehaviorTest.res/*DecisionTest.res/*ProjectionTest.res. Tests mirrorsrc/1:1 (so the PPX folder-segment heuristic resolves the kind). Ship one*_GWT.resper Aggregate / StateChangeSlice / StateViewSlice / StateViewSliceStream / ReadModel / AutomationSlice / InboundTranslationSlice / OutboundTranslationSlice. AWS adapter packages and*-specpackages 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.gwtat the top of a test file and let the PPX infer the kind; the explicitinclude <Kind>_GWT.Make(<Spec>)form also works. See.claude/rules/app-developer.mdfor 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
decideis 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
thenNoEventon 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.
11. Related documentation
- Given/When/Then design notes — design rationale, alternatives, and the canonical format / hint-table specifications.
- Component testing guide — where Jest-based component / integration tests live (not the slice level).
- Aggregate vs DCB decision guide — when to reach for an aggregate and when a StateChangeSlice.
.claude/rules/app-developer.md— full PPX annotation reference (including@@reventless.gwtpayload forms).