Skip to content

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.

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.

Each pin answers one choice point, keyed by the thing the model already names:

Choice pointPinMeaning
if "<cond>":"<cond>": yes / notake (or skip) that arm
try { } success"try": passthe 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", oka 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 / elapsesthe 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.

Each assertion is a step reference with an optional modal tail:

  • happens (the default — a bare reference reads as happens): 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.

Run the suite with the CLI:

Terminal window
archlang test --coverage

It 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.

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.

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.

  • 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, the await that elapses — those are what a diagram edit silently breaks. The happy path rarely does.
  • One trace per test. Constrain the when until the under-constrained warning clears; a mushy test has mushy counterexamples.