Skip to content

Stimuli

A stimulus drives the software: it arranges the state an example starts from and acts on it. It is one of Varar’s two kinds of step functions. The other is sensors, the read-only observations.

The names are a hardware analogy: you put a stimulus into the system, and you read its response with sensors.

const { stimulus, sensor } = steps(() => ({ total: 0 }))
stimulus('I add {int}', (state, n) => ({ ...state, total: state.total + n }))
sensor('the total is {int}', (state) => state.total)

In your prose the stimulus covers both the context (arrange) and the action (act) — Varar never matches keywords; see Test anatomy for why the concepts and the mechanism are decoupled.

A stimulus receives the current state as its first argument, followed by the values the expression captured. It evolves state by returning the complete next state — full replacement, in every port. The value you return is the new state, so a return that leaves a field out drops it rather than preserving it. Spread the current state to keep the rest: (state) => ({ ...state, count: 1 }).

Returning nothing leaves state unchanged. Convention is new value out, always.

const { stimulus } = steps(() => ({ greeting: '', count: 0 }))
stimulus('I greet {string}', (state, name) => ({ ...state, greeting: `Hello, ${name}!` }))
stimulus('I add {int}', (state, n) => ({ ...state, count: state.count + n }))
  • Returning nothing leaves state unchanged — right for a stimulus whose side effects live entirely in the system under test. (In Java and Kotlin, return the received state unchanged instead.)
  • In TypeScript, Python and Ruby, returning anything that isn’t an object (or nothing) is a ReturnShapeError. A stimulus never returns values for comparison — that’s the sensor’s job.
  • Mutating state is your call, not Varar’s. Varar hands you the value your factory (or your last stimulus) produced, untouched — it is not frozen, copied or retyped. Declare your state readonly if you want the compiler to stop you; in Java and Kotlin the record/data class already does. Evolution is meant to happen by returning, and mutating instead will confuse the next reader, but nothing at runtime forbids it — a state that holds a DB client or a page object needs those objects live.
  • There is no merge. The returned object replaces state wholesale, so a field the return omits is gone. This is the same model the typed ports get from their record/data-class state, where a partial return cannot even be expressed.

steps declares the state its step file’s examples start from. Every example gets a fresh state from the factory, so examples never leak into each other; steps defined in different step files never see each other’s state.

The factory is optional. A step file whose steps are pure — nothing to arrange, nothing to evolve — calls steps bare; handlers receive an empty state they can ignore:

const { stimulus, sensor } = steps()
sensor('the square of {int} is {int}', (_state, n) => [n, n * n])

A trailing data table or fenced code block arrives as the last handler argument, after the captured parameters — a table as a list of rows (header row first), a doc string as its exact text:

stimulus('these books exist:', (state, rows: ReadonlyArray<ReadonlyArray<string>>) => ({
...state,
books: rows.slice(1).map(([title, author]) => ({ title, author })),
}))

A stimulus consumes these as input. To check a table or doc string against what the software produced, use a sensor — see Check tables and doc strings.

A stimulus handler may be async (async function in TypeScript, async def in Python, suspend in Kotlin); the runtime awaits it before the next step runs.

Error Raised when
ReturnShapeError the handler returns something that isn’t a complete state object or nothing

Any exception the handler itself throws fails the example, anchored to the step’s line in the Markdown.