Skip to content

Sensors

A sensor is a read-only observation. It reads state, returns what the software actually produced, and Varar compares that against what the Markdown claims. A sensor never changes state, and you never write an assertion — the document is the assertion. It is one of Varar’s two step kinds — the other is the stimulus, which drives the software.

The word comes from Birgitta Böckeler’s Harness Engineering: a test harness surrounds the system with instruments the way a hardware rig does — you drive the system with stimuli and read its behaviour back through sensors, rather than reaching inside it.

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 sensor is the outcome you expect; see Test anatomy for how context, action and outcome map onto the two step kinds.

This page is the reference for what a sensor may return and how each return value is compared. The rules are identical in every port (TypeScript, Java, Kotlin, Python, Ruby).

A cell is one value Varar compares — the atomic unit of a check. A cell can sit in a table, in a header-bound row, or in a paragraph, where an expression parameter captures it. (See Varar overview for the spreadsheet analogy this comes from.)

A sensor’s slots are the positions in its return, in order. A slot holds either a single cell or a whole table of cells:

  1. each parameter captured by the expression ({int}, {word}, {string}, custom types), left to right — one cell each;
  2. the trailing data table or doc string, if the step has one (always last, at most one) — a table is a grid of cells; a doc string is one cell compared whole.

So a two-slot sensor may well compare a dozen cells: one for its {int}, and one per cell of the table that follows.

The return value maps onto the slots by count:

Slots Return Example
0 nothing (undefined / None / null) () => { assertSomething() }
1 the slot’s value, bare (state) => state.total
2+ an array/list, one element per slot, in order (state) => [state.n, state.s]

With one or more slots the return is required. Returning nothing is a ReturnShapeError, not a pass — see Returning nothing.

A sensor with no parameters, no table and no doc string has nothing to compare a return value against. Throw to fail, return nothing to pass:

sensor('the alarm fired', (state) => {
if (!state.alarm) throw new Error('no alarm')
})

Returning any other value is a ReturnShapeError. This is deliberate: a returned value here would silently assert nothing, and the author almost certainly believed it was being checked.

sensor('the total is {int}', (state) => state.total)

The return is the slot’s value. It is never interpreted as a positional array — so wrapping it (return [state.total]) fails the comparison, because [42] is not 42.

This also resolves what would otherwise be an ambiguity with custom parameter types whose parse produces an array:

const { sensor } = steps(() => ({ dice: [5, 6] })).param(
'numbers',
/\d+(?:, \d+)*/,
(raw) => raw.split(', ').map(Number),
)
// "The dice show 5, 6" — {numbers} transforms to [5, 6]
sensor('The dice show {numbers}', (state) => state.dice) // deep-equal [5, 6] vs [5, 6] ✓

Because a single-slot return is always the bare value, [5, 6] here is unambiguously the value, deep-compared against the transformed parameter — never mistaken for a two-slot positional array.

Two or more slots — return a positional array

Section titled “Two or more slots — return a positional array”
sensor('I should have {int} cukes in my {word} belly', (state) => [
state.count,
state.bellyName,
])

The array must have exactly one element per slot; a different length or a non-array return is a ReturnShapeError. Each element is compared against its slot. When the step also has a trailing table or doc string, it occupies the last element:

sensor('Greet {word}:', (state, name, _body: string) => [name, `Hello, ${name}!\n`])
Slot holds Cells compared How
an inline parameter one deep equality against the transformed captured value, so {int} compares numbers and a custom type compares whatever its parse function produced
a whole table one per table cell exact string comparison, cell by cell — see Check tables and doc strings
a doc string one exact string equality of the whole block, trailing newline included; expected/actual are quoted so a whitespace-only difference stays visible

Every mismatch is a CellMismatchError carrying one diff per failing cell, anchored to that cell’s span in the Markdown.

A sensor bound to a sentence that names a table’s columns runs once per row and returns a row object keyed by the header — cells, not slots:

sensor('a decimal and a roman number', (_state, row: { decimal: string; roman: string }) => {
return { decimal: row.decimal, roman: toRoman(Number(row.decimal)) }
})

Each returned column is compared cell by cell against that row. This is the one sensor shape that bypasses the slot contract — a row is not a slot, it is a set of cells, one per bound column.

Returning undefined (TypeScript), None (Python), null (Java, Kotlin) or nil (Ruby) is allowed only for a zero-slot sensor, where there is nothing to compare against: throw to fail, return nothing to pass.

A sensor with slots must answer them. Returning nothing raises a ReturnShapeError:

a sensor with 1 slot(s) must return one value per slot, got nothing

That closes a silent-coverage hole. The document makes a claim, so something has to check it — and a mistake as small as a typo’d property access returns undefined, which would otherwise skip the comparison and leave the example green while verifying nothing. The same applies to a header-bound row step: it must return its row object.

Throwing is always allowed, at any slot count — a plain throw or your test framework’s own assertions fail the step just as a mismatching return does. What is no longer allowed is answering nothing at all when the document asked a question. If a capture is awkward to echo back (a greedy {word} swallowing a trailing period, say), match the punctuation in the expression itself — The result is {word}. — so the cell holds just the value you want to compare.

Error Raised when
CellMismatchError one or more cells differ — an inline capture, a table cell, a header-bound row’s cell, or a doc string; carries one span-anchored diff per failing cell
ReturnShapeError the return doesn’t fit the slots: a value from a zero-slot sensor, nothing from a sensor with slots (or from a header-bound row step), a non-array or wrong-length array for 2+ slots, or a malformed table

Because every diff is anchored to a source span, editors highlight exactly the failing characters in the Markdown and show the actual value in place.

A sensor with several slots is answering several independent questions the document asked, and Varar needs to know which returned value belongs to which. Positional mapping — same order as the sentence — does that without naming ceremony. With one slot the position is unambiguous, so the wrapper would be pure noise; that’s why one slot takes the bare value.