Testing Processes
Chapter 7 showed that a process is where the architecture lives: the arrows on the board are derived from Caller > Callee steps. A process also branches — an if on a value, a try that may fail, a select that runs some cases, an await that may time out. Each of those is a decision the diagram leaves open. A test closes them: pin the decisions, then assert the trace that results.
The key idea: a process test is a mock by construction. It never executes anything and never touches business data. It checks the shape of an outcome — which steps ran, in what order, how the flow ended, which compensations reversed. That is exactly the layer a diagram can silently break, and exactly what a static model can verify honestly.
The test construct
Section titled “The test construct”A test names a process, pins its choice points in a when block, and asserts the trace in a then block:
process #chk Checkout { Orders if "high value": > Shipping.express else: > Shipping.standard Orders try { > Payments.charge } catch declined { > Channels.notify } Orders select one "channel" { sms: > Channels.sms email: > Channels.email } Orders > Orders.place}
test "high-value order, card charged, emailed" of Checkout { when { "high value": yes "try": pass channel: [email] } then { Shipping.express Payments.charge Channels.email Orders.place Channels.sms never }}The header is test [#id] "<name>" [of <Process>]. A test declares at file top level, inside a module body, or in <Module> test … — the same placement grammar view and policy use. test, when, and then are contextual keywords: a module or step named test still parses; the keyword only binds in its slot.
Pinning the when
Section titled “Pinning the when”Each pin answers one choice point, keyed by the thing the model already names:
| Choice point | Pin | Meaning |
|---|---|---|
if "<cond>": | "<cond>": yes / no | take (or skip) that arm |
try { } success | "try": pass | the guarded steps succeed |
| a failing step | <Callee.step>: fails or fails "cause" | inject a failure (routes to the matching catch, or fails the process) |
| a retry loop | <Callee.step>: fails "x", ok | a per-attempt outcome sequence |
select "<head>" | <head>: [caseA, caseB] | which cases ran, in order |
| a step delay | <Callee.step>: after <dur> / takes <dur> | business / system time on the step |
an await … as <label> boundary | <label>: within / elapses | the event arrives in time, or the timeout fires |
Pin enough to resolve the process to a single trace. Leave a choice unpinned and the test spans many traces at once — the tool warns (under-constrains the process), and its verdict gets mushy. Add pins until the warning clears.
Asserting the then
Section titled “Asserting the then”Each assertion is a step reference with an optional modal tail:
happens(the default — a bare reference reads ashappens): the step occurs on every trace. Add a count:Payments.charge happens 2,happens >= 1.never: the step occurs on no trace.sometimes: the step occurs on at least one trace — a witness, useful when the pins still leave alternatives.fail ["cause"]/finish ["cause"]: the trace ends with that terminator (all= every live branch).time (system|business) <op> <dur>: a latency budget over the trace (see Chapter 34).parallel { … }: an order-free group — every member occurs, in any order.
Order is positional and implies occurrence. Listing A then B asserts both happen and that A precedes B. A saga’s unwind is asserted as an ordinary occurrence — reverse order reads as rollback order.
Coverage is the honest metric
Section titled “Coverage is the honest metric”Run the suite with the CLI:
archlang test --coverageIt prints each test’s PASS / FAIL / OPEN (OPEN = the pins left a loop or trace-set unbounded — never a silent pass) and a choice-point coverage summary: the fraction of a process’s branch / select / failure / timeout decisions that at least one test pins. A green suite over half-covered choice points is under-tested, and the number says so — trust it over the pass count. Drop a test and coverage falls, telling you exactly what went untested.
Governing with tests
Section titled “Governing with tests”Two selector aspects turn tests into a gate (see Chapter 32):
@tested— the process has at least one passing test.@testCoverage <op> <n>— its choice-point coverage clears a bar.
Use them in a policy so an under-tested process fails review rather than shipping unnoticed. A require obligation is <subject>: <obligation>, with this bound to each matched element (Chapter 32) — so the boolean check on the subject itself is this and where (…):
policy CriticalFlowsTested { require process and where (@@critical): this and where (@tested and @testCoverage >= 80)}A test anchored by a #id also drives the regression gate: archlang change-check compares base against head, and a test that passed on the base but fails on the head is reported as a regression in the diff.
Capturing a test from the scrubber
Section titled “Capturing a test from the scrubber”The fastest way to author a test is to not type one. Step a process in the flow view, answer its choices as you go — pick a branch, inject a failure on a step, choose which select cases run, set a delay — then click Save as test. The answered session is captured as a test block appended to the process’s file, and re-running it reproduces the exact trace you scrubbed. Treat the capture as a starting point: rename it to say what it verifies, and trim the assertions down to the ones that pin the outcome you care about.
What to test
Section titled “What to test”- Test the shape, never the data. Assert steps, order, terminators, compensations. Business values aren’t in the model; a test reaching for them is testing the wrong layer.
- Cover the failure arms. The
fails-to-catch, the saga rolling back, theawaitthatelapses— those are what a diagram edit silently breaks. The happy path rarely does. - One trace per test. Constrain the
whenuntil the under-constrained warning clears; a mushy test has mushy counterexamples.