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