Skip to content
hekla

Tests

A test is a declaration, so the cases live beside the code and run with the same binary.

A test is a declaration like any other. It lives in the same program, it is checked by the same compiler, and it runs against a real log, real read models and a real key store. There is no framework to adopt and nothing to mock.

test "a new address joins the list" {
  run JoinWaitlist { entry_id: ADA, email: "ada@example.com" }
  expect @waitlist.joined { entry_id: ADA, email: "ada@example.com" }
}

test "the same address twice is refused" {
  given @waitlist.joined { entry_id: ADA, email: "ada@example.com" }
  run JoinWaitlist { entry_id: GRACE, email: "ada@example.com" }
  expect reject AlreadyJoined
}

Run them with hekla test ..

Shape

test "<name>" {
  <given>*
  <respond | erased | secret>*
  <action>
  <expect>*
}

Those sections appear in that order. Each is optional except the action, and there is exactly one action.

The name is a string rather than an identifier, because it is what a failure report reads out. Two tests in one program may not share a name.

given is a literal log

given @event.path { ... } appends one event, and several make a log in the order written. Every field must be given, the same rule emit follows, and unlike emit there is no bare-name shorthand.

There is no given run PlaceOrder(...), on purpose. A fixture built by running the thing under test means one broken command fails every test that used it as scenery, and the report then names the wrong test. Raw events keep a failure local.

There is also no way to seed a fold directly, because a folded value is derived from the log and nothing else.

Scripting the world

Statement Means
respond "<url>" <status> queue one HTTP reply for that URL
respond "<url>" <status> { <json> } the same, with a body
respond "<url>" timeout a transport failure, which the runtime absorbs and retries
erased <subject> "<id>" that subject’s key is already destroyed
secret <NAME> = "<value>" this deployment holds that credential
secret <NAME> = none this deployment does not

Replies are a queue per URL, taken in order, so respond url 503 then respond url 200 says the first attempt was absorbed and retried.

erased is the only way to write a shredded-key test, because a test cannot call erase itself.

The action decides the expectations

Action Runs
run Command { ... } the command against the given log
project Projector the projector over the whole given log
deliver Effect the effect over the whole given log, following it as an invoke lengthens it

run

Expectation Matches
expect @path { ... } one appended event, in order
expect nothing the command appended no events
expect invalid("<message>") an invalid outcome
expect reject <Name>, expect reject <Name> { ... } a refusal, and optionally its fields

Appended events match one for one and in order, and an event the test did not write is a failure. Every field of an event is matched, so list all of them.

expect nothing is a real assertion. “Placing the same order id twice is a no-op” is worth stating, and writing no expectation at all would say it only by accident.

project

Expectation Matches
expect Entity[key] { ... } the row exists and the listed columns match
expect no Entity[key] there is no such row

A row is matched on the listed columns only, because a row is wide and most of its columns are carried through untouched by the handler under test. That is the deliberate difference from an event, which is matched on all of its fields.

expect no is what makes the difference between patch and update testable.

deliver

An effect’s output is a trace: the ordered list of what it did to the world.

Expectation Matches
expect http.<verb>("<url>") a call to that URL, body unchecked
expect http.<verb>("<url>", { ... }) the same, and the body matches the listed keys
expect invoke Command { ... } an invoke with exactly those arguments
expect erase(<subject>, "<id>") an erase naming the subject field and the id
expect log("<message>") one log line
expect fail("<message>") an arm returned fail
expect skipped an arm hit a shredded key
expect nothing the effect did nothing observable

The trace and the expectations line up one for one, including every log.

A request body is matched on the keys the test writes, because bodies are often large. invoke arguments are matched exactly, because a command’s arguments are its whole input.

A number in a body is compared by its spelling: 3 and 3.0 are the same number and not the same JSON, so write what the wire carries.

deliver drives, so there is no position to write. The given log is treated as a catch-up batch, which means an on latest arm collapses over it exactly as it would in production. An on live arm is delivered as on, because its boundary is a position the runtime resolves at first activation rather than a property of the log.

What a test cannot assert

State. There is no expect token == "x". What an arm did with a fold is already visible in the trace.

An arbitrary boolean. There is no assert <expr>. A case is a table of inputs and outputs; the moment it can compute, it is a program with its own bugs, and then you need tests for your tests.

hekla test and hek test

hek test runs the language’s own harness. hekla test runs the same declarations through the runtime, synthesising the event envelope the runtime would produce, so a test sees what production sees.

Use hekla test for a hekla project. It is the one whose results mean something about deployment.

hekla test .
ok: "a new address joins the list"
ok: "the same address twice is refused"

2 passed, 0 failed