Testing
suite blocks
Section titled “suite blocks”A test file is a suite block naming its target unit, containing named cases:
suite counters { case "a fresh counter starts at zero" { let n <- Counter(CounterId.unsafe("fresh")).current() expect n == 0 }}Case descriptions within a suite must be unique
(bynk.suite.duplicate_case_name); the target must exist
(bynk.suite.unknown_target). Test files live under the project’s tests/ tree —
see Lay out a project.
A case drives the target’s services by their natural surface — resolved against the declared handler and checked for arity and argument types:
| service | address |
|---|---|
on call | svc.call(args) |
from http | svc.GET("/path"), svc.POST("/path", body) — the path is the route pattern, then the handler’s params (path params, then body) |
from cron | svc.schedule("<expr>") — matched to the handler with that schedule |
from queue | svc.message(msg) |
A route/schedule the service does not declare is bynk.test.service_unknown_route;
svc.call(...) on a service with no on call handler is
bynk.test.service_no_call_handler.
Acting as an actor — by <Actor>(<identity>)
Section titled “Acting as an actor — by <Actor>(<identity>)”A handler guarded by an actor (by u: User) runs as a verified identity. A case
supplies that identity with a call-site by clause on the effect-let:
case "each owner's list is private" { let _ <- api.POST("/todos", AddRequest { title: "bob's" }) by User("bob") let mine <- api.GET("/todos") by User("carol") expect mine is Ok(_)}by User("bob")— the actor and the identity value. The value is typed against the actor’s identity type, soby User("")failsUserId’s refinement.by Visitor— a unit-identity actor takes no argument (bynk.test.actor_no_identityif one is given;bynk.test.actor_identity_requiredif an identity-carrying actor is written bare).- cron and queue run as their internal actor and need no
by.
At the unit tier the identity is given, not verified — the handler runs
in-process against fresh, per-case agent state. Promote the same case to as system and the identical body drives the deployable Worker: the address
becomes a real fetch into the public route table, by User("bob") is signed
into a JWT the real auth seam verifies, and the HttpResult is decoded from the
Response. The developer writes no auth — the framework signs a valid credential
from the by clause; proper auth (real IdPs, expired/forged tokens) is an
end-to-end concern, not the system tier’s. A single-context from http service
qualifies for as system (it has a real serialisation edge); a cron-only target
does not (bynk.tier.system_needs_wire).
Driving a rejection with Wire
Section titled “Driving a rejection with Wire”A typed argument is valid by construction, so the boundary can never reject it. To
test the rejection path — the part the boundary exists for — a system-tier case
passes a raw Wire(<String>) argument: the string reaches the router
unvalidated, exactly as an over-the-wire request would, so a refinement
violation or malformed JSON is refused before the handler runs.
case "an empty sku is rejected at the boundary" as system { let r <- api.POST("/cart", Wire("{\"sku\": \"\"}")) by User("alice") expect r is Rejected(_)}A Wire-carrying call yields Rejected(kind) | Handled(_) instead of an
HttpResult: Rejected when the router refused the input before the handler,
Handled when it ran (a valid raw body promotes to the handler, so
expect r is Handled(_)). The rejection’s kind is discriminable — the nested
pattern tests it:
expect r is Rejected(_) -- any boundary rejectionexpect r is Rejected(RefinementViolation(_)) -- specifically a refinement violationexpect r is Rejected(MalformedJson(_)) -- specifically malformed JSONThe kinds are RefinementViolation, MalformedJson, and StructuralMismatch.
The outcome is checked at runtime, not statically typed, so a mistyped kind
name is a case that fails (the pattern never matches) rather than a compile
error. Wire is legal only as a service-address argument in a system-tier
case — there is no wire to be raw about at unit
(bynk.test.wire_needs_system) — and no refined value is ever built from it: the
router validates the raw string, which is the whole point.
Testing the auth seam with by Nobody
Section titled “Testing the auth seam with by Nobody”by User("bob") presents a valid, framework-signed credential the real seam
verifies. To test the rejection — an unauthenticated request — a system-tier
case drives the route as by Nobody: the request carries no Authorization
header, so the seam refuses it before the handler runs.
case "no credential is rejected at the seam" as system { let r <- api.POST("/cart", Item { sku: "widget" }) by Nobody expect r is Rejected(Unauthorized)}by Nobody yields the same Rejected(_) | Handled(_) outcome as a Wire call:
a 401 from the seam is Rejected(Unauthorized). It presents no identity
(by Nobody(...) is an error), and is meaningful only at system, where the
real seam exists (bynk.test.credential_needs_system). A validly-signed but
expired or forged credential is an end-to-end concern, not the system tier’s.
Testing the wrong method — the 405 fall-through
Section titled “Testing the wrong method — the 405 fall-through”Address an existing path with a method it declares no handler for to test the
router’s method fall-through. The path must be a declared route (a genuinely
unknown path is still bynk.test.service_unknown_route); the wrong method drives
the 405:
case "DELETE is not allowed on /cart" as system { let r <- api.DELETE("/cart") -- /cart is declared for POST, not DELETE expect r is Rejected(MethodNotAllowed)}No handler runs, so the call takes no arguments and no by clause. Like the
other boundary refusals it yields the Rejected(_) | Handled(_) outcome — a
405 is Rejected(MethodNotAllowed).
expect
Section titled “expect”expect <bool-predicate> checks a predicate. It exists in both statement form (a
line in a case body) and expression form (e.g. inside a match arm). The
predicate must be Bool (bynk.expect.not_bool), and expect is valid only
inside a case (bynk.expect.outside_case). It is the same predicate surface
as invariant/ensures — is, implies, the operators, pure methods (one
predicate surface, ADR 0144) — so it pairs naturally with is: expect r is Ok(_). When the predicate is a top-level comparison (==, !=, <, <=, >,
>=), a failure reports the predicate and its expected-vs-actual operands, not
just a location.
Claiming a fault — expect <call> faults
Section titled “Claiming a fault — expect <call> faults”A fault is not a value. A capability that fails (a stub … fails, or a
real provider’s failure) and an invariant violation both throw: the fault
propagates out of the handler, and out of the case, failing it before its next
expect runs. A fault is untyped, and no caller can handle it, so it never
surfaces as an Err. To test that a call faults, claim it:
case "a store fault faults the quote" { stub Kv.get(_) fails expect Prices(Val[AcctId]).quote("GBP") faults}expect <call> faults awaits the call and holds when it throws. When the call
returns instead, the case fails with the call returned without faulting. Any
fault satisfies the claim, whatever raised it; a failed expect inside the call is
the case failing, not the call faulting, and propagates as usual.
- The subject is the call itself, an
Effect[_], not a value bound from it.let r <- svc.call()has already run the call, soexpect r faultsisbynk.expect.faults_not_effect. - The claim is a test’s observation, not a handler. It exists only in a
case(bynk.expect.outside_case); production code still cannot catch a fault. - It needs an in-process call. At
systema fault reaches the case as an error response from the deployed Worker, never a throw, so asystem-tier case cannot claim one (bynk.test.faults_needs_in_process); assert the response instead. - It takes no
byclause, so it claims faults only on a handler that needs no caller identity; addressing one that does isbynk.test.principal_required.
A sequenced stub makes a fault and a later success observable in one case:
case "the store recovers" { stub Kv.get(_) returns each [fails, Some(row)] expect Prices(Val[AcctId]).quote("GBP") faults -- the first call faults let r <- Prices(Val[AcctId]).quote("GBP") -- the second returns expect r is Ok(_)}Tiers — the as <tier> clause
Section titled “Tiers — the as <tier> clause”A case runs at one of three tiers, declared with an as <tier> clause in its
header. The three names are the testing pyramid — unit, integration, system —
and a tier is one body promoted, not a distinct kind of test:
| Tier | Collaborators | Wire crossed? |
|---|---|---|
unit (default, elided) | in process; a seam may be stubbed with stub | no |
integration | real, within one context | no |
system | contexts stood up as the Workers they deploy as | yes — the real serialise → JSON → deserialise edge |
suite money { case "never negative" { … } -- as unit, by default case "used in a payment" as integration { … } -- real collaborators, one context, no wire case "checkout end to end" as system { … } -- contexts wired across the real edge}unit is the default and is never written. as also sits on the suite
header, setting a default every case inherits and may override (case wins):
suite checkout as integration { -- every case defaults to integration… case "small order authorises" { … } -- as integration (inherited) case "a unit-level edge" as unit { … } -- case overrides the suite default}A case’s effective tier is case.tier ?? suite.tier ?? unit. Between unit and
integration, promotion changes only the header, and the body is identical.
systemis a suite of its own. Asystemcase runs against deployed Workers and addresses a service by its context path (shop.orders.place(…)), notplace.call(…), so asystemsuite holdssystemcases only. Mixing tiers in one suite isbynk.tier.mixed_system_suite.- Crossing a context needs
system. Belowsystema case runs in-process: no consumed context is stood up, andstubdoubles capabilities, not a context’s services. Aunitorintegrationcase, or aproperty(always in-process), that reaches another context’s service, directly or through a service or agent handler that calls one, isbynk.tier.cross_context_needs_system. - Participants are inferred, not listed:
systemderives its wired collaborator set from the unit under test’s transitiveconsumesgraph. There is nowiresclause. systemneeds a serialisation edge: asystemsuite must cross a real serialise → JSON → deserialise boundary — either two or more wired contexts, or a single target that exposes anhttpservice (its public boundary). A target with neither isbynk.tier.system_needs_wire. (Aqueueservice serialises its message too, but driving a queue over a real wire atsystemis a later slice, so a queue-only target does not yet qualify.)integrationcarries no such rule — it is real collaborators within one context, no wire.- Tiers are
case-only: apropertygenerates and does not promote, so a suite-levelasbinds itscasemembers only, and apropertyheader has no place for anas(it does not parse). - The agent-state lifecycle is fixed across tiers: the unit under test is always a real in-memory instance, keyed normally, fresh per case; only its collaborators’ realness and whether sends cross a serialisation boundary change.
See Test tiers for the full guide, and
bynk.tier.* errors.
stub — per-seam test doubles
Section titled “stub — per-seam test doubles”as <tier> sets the default provision of every seam; a stub clause
overrides one method’s provision under test — an explicit call pattern on the
left, a value or fails on the right, never a computed body:
suite pricing { stub Rates.lookup("GBP") returns 1.25 -- suite-scoped: applies to every case stub Rates.lookup(_) returns 1.0 -- fallback; first matching clause wins
case "a store fault faults the quote" { stub Kv.get(_) fails -- case-scoped: overrides for this case expect Prices(Val[AcctId]).quote("GBP") faults }}This substitutes a consumed seam of the unit under test — the third of the seam
triad: consumes declares a seam, given requires it, and stub
substitutes it under test (its own keyword since #548; formerly a pun on the
production provides).
- The left is
Cap.method(<pattern>, …)— the capability, the method, and an argument pattern per parameter. A pattern is the one predicate surface: a literal ("GBP",1000),_(any), or anisnarrowing. Clauses for the same method are tried top to bottom, first match wins, so put specific before fallback. - The right is
returns <value>orfails— a value or a fault. AnErris an in-band outcome: stub it withreturns Err(…)and assert it withexpect r is Err(_).failsinjects a capability fault: the operation throws, as a real provider’s failure would, and the fault propagates out of the handler and out of the case unless the case claims it withfaults. It is never a block: a double that needs logic is the signal to promote the tier. stubis capability-only. An agent’s realness is the tier’s job, not a provider’s, sostubtargets a capability seam only. Overriding a capability the unit does notconsumesisbynk.stub.not_a_seam; naming an operation the capability does not declare isbynk.stub.unknown_op; areturnsvalue whose type disagrees with the operation’s result type isbynk.stub.rhs_type.- Precedence: case
stub> suitestub> the tier default, per operation. A case-scoped clause applies to its own case only. An operation no clause covers (for this case) reaches the tier default, so stubbingKv.getleavesKv.putworking.
Bare observation needs no stub — the recording proxy
records calls at the seam regardless. You reach for stub only when the case
depends on a collaborator’s return.
Sequenced stub — returns each
Section titled “Sequenced stub — returns each”A single value cannot express a collaborator whose successive calls differ — an
advancing clock, a Kv.get that returns None then Some, a network that fails
twice then succeeds. The return-sequence form supplies one outcome per call, in
order:
stub Clock.now() returns each [1000, 2000, 3000] -- three successive successesstub Kv.get(_) returns each [None, Some(row)] -- None, then Some…stub Net.fetch(_) returns each [fails, fails, ok(resp)] -- fails twice, then succeedsreturns each [<outcome>, …]— theeachdistinguishes “one outcome per call” from a single call that returns a list value (returns [a, b]still returns the two-element list once). Each<outcome>is a value (a success), the atomfails(a fault), orok(v)when a fault and a success value must sit in one sequence.- Exhaustion: the last outcome repeats (steady state).
[fails, fails, ok(resp)]is “fails twice, then succeeds forever”;[1000, 2000, 3000]holds at3000after the third call. A malformed sequence (e.g. empty) isbynk.stub.bad_sequence. - A collaborator that must compute its next return (a delta-advancing clock, a threaded cursor) exceeds a fixed sequence and is a reserved virtual fixture — a named follow-on, not shipped in v0.118.
See bynk.stub.* errors.
Platform capabilities under test
Section titled “Platform capabilities under test”A platform capability that a context consumes (consumes bynk { Clock, Logger },
consumes bynk.cloudflare { Kv }) has no real provider in a test. At the unit
and integration tiers bynkc test gives each one a deterministic test
double instead. It returns the same answer on every run, does no I/O, and is
fresh for each case:
| Capability | Under test |
|---|---|
Clock | now() reads the epoch (Instant.fromEpochMillis(0)) |
Random | uuid() counts up (00000000-0000-4000-8000-000000000001, …002, …); int(lo, hi) draws from a fixed seed |
Secrets | get(_) is None |
Locale | current() is "en" |
Logger | records, prints nothing |
Events | records, delivers nothing |
Idempotency | in memory; remember’s expiresAfter is accepted and ignored |
Kv | in memory; putTtl’s TTL is accepted and ignored |
Fetch | send(_) faults: a test never reaches the network |
Expiry is not modelled: a value written with a TTL stays readable for the whole
case, so a test of TTL expiry on Kv or Idempotency stubs the read instead
(stub Kv.get(_) returns each [Some(v), None]). An agent Cache store’s
@ttl does consult the Clock, so stubbing Clock.now drives its expiry.
A case reads a different answer with an ordinary stub, which
overrides just the operations it names. Every other operation keeps the double:
case "a session expires after its ttl" { stub Clock.now() returns each [Instant.fromEpochMillis(0), Instant.fromEpochMillis(1860000)] do Sessions("s").login("t1", "ann") let who <- Sessions("s").whoami("t1") expect who is None}A handler that calls Fetch.send needs a stub Fetch.send(_) returns …; without
one, the case fails with a message naming the missing stub. Platform
capabilities are seams like the context’s own, so
observation works on them too
(expect Logger.info called once with msg == "…").
Val[T] — value fabrication
Section titled “Val[T] — value fabrication”Val[T] fabricates a valid inhabitant of T drawn from its refinement domain;
Val[T](pin) pins a specific one, refinement-checked at compile time.
| Kind | Bare Val[T] yields |
|---|---|
Int where Positive | 1 |
Int where NonNegative | 0 |
Int where InRange(a, b) | a |
String where MinLength(k) / Length(k) | a string of length k |
String where Matches(…) | error — must pin (bynk.val.needs_pin) |
| sum | the first variant (payloads recursively fabricated) |
| record | every field fabricated |
| opaque | .unsafe(<base zero>) |
Val[T] is test-only (bynk.val.outside_test). A pin must be a compile-time
literal (bynk.val.pin_not_literal), must satisfy the refinement
(bynk.val.literal_violates), and is only accepted where the kind supports it
(bynk.val.pin_unsupported). See
bynk.val.* errors.
property / for all — generative tests
Section titled “property / for all — generative tests”A property is the generative sibling of case, legal in the same suite. Where
a case supplies its subjects, a property generates them and checks that a
claim holds across many:
property "more discount, never a higher price" { for all p: Price, a: Percent, b: Percent where a <= b { expect discount(p, b) <= discount(p, a) }}for all x: T binds x to a generated inhabitant of T (comma-separated for
multiple bindings). An optional where <pred> — a pure Bool — filters generated
tuples before the body runs (a non-Bool filter is bynk.property.where_not_bool).
The body is one or more expects: the same predicate surface as a case, an
invariant, or an ensures.
Generation draws from T’s refinement domain and includes boundary values:
| Type | for all / Val generates |
|---|---|
Int where Positive | 1, small positives, and the boundary |
Int where NonNegative | 0 and small non-negatives |
Int where InRange(a, b) | a, b, and interior values |
String where MinLength(k) / Length(k) | strings at and above length k |
String where Matches(…) | must pin (bynk.val.needs_pin) — no generator |
| sum | each variant |
| record | each field generated |
| opaque | over the base type |
A type must be refinement-generable to appear in for all (or Val): a
String where Matches(re) has no generator and must be pinned instead; an agent
cannot be generated (bynk.val.agent_not_generable) — behavioural agent testing
over handler sequences is a later slice.
When a property earns its keep. Reach for a property when a claim should
hold across a range of inputs — a relationship between inputs and an output
(monotonicity, a round-trip, an ordering). Reach for a case when one specific,
named scenario is the point. A property that merely re-checks a refinement its
type already guarantees (e.g. for all q: Quantity { expect q > 0 } when
Quantity is Int where Positive) proves nothing and is flagged
bynk.property.restates_refinement (a conservative, syntactic check).
On failure a property reports the case count, the run’s root seed, and a shrunk
counterexample with a copy-paste reproduce line — see
Run your tests and
bynk.val.* errors.
History properties — for all run: History[Agent]
Section titled “History properties — for all run: History[Agent]”A property generates values; a history property generates a run of an
agent. for all run: History[Wallet] binds run to a generated, driven
call-history of the Wallet agent — the generative sibling of property now spans
values and whole behaviours:
suite demo.wallet { property "no accepted spend without a prior accepted top-up" { for all run: History[Wallet] { expect run.all((s) => (s.call is Spend && s.accepted) implies run.upTo(s).any((p) => p.call is TopUp && p.accepted)) } }}The runner:
- generates a bounded random sequence of
Wallet’s handler calls — each handler chosen uniformly, each argument drawn from its parameter’s refinement domain (the same generator, seed, and shrinker a valuefor alluses); - drives the sequence against a fresh
Walletfrom its initial state, invoking the real handlers and their real invariants — so every state inrunis one a handler actually reached (reachability by construction, never a fabricated state); - binds
runand evaluates the predicate; on failure it reports the seed and a shrunk minimal failing sequence.
A history is a List[Step]
Section titled “A history is a List[Step]”run is an ordinary List — assert it with the surface you already know (.all /
.any / indexing / .length()), exactly as trace(Cap.op) is a
List. There is no temporal vocabulary: “always P” is run.all((s) => P),
“eventually P” is run.any((s) => P), and “P before Q” is a quantified prefix check
via run.upTo(s) (the history strictly before step s). Each Step carries:
| Field | Type | Meaning |
|---|---|---|
.call | a sum over the agent’s handlers (Spend { amount }, TopUp { amount }) | which handler ran, with its generated arguments — matched with is / match. The variant is the handler name with its first letter upper-cased. |
.accepted | Bool | whether the handler committed a new state (vs. rejected — an invariant / transition refusal leaves the state uncommitted) |
.old / .new | the agent’s state | the committed old→new pair (the same old/new a transition sees), so a step is a reached edge of the state graph |
History[T]requiresTto be an agent — only an agent has handlers to sequence and reachable states to observe (bynk.history.not_an_agent). It is legal only infor allposition inside aproperty; anywhere else it isbynk.history.outside_property(it is a generator, not a value type).- The agent must be drivable: every handler parameter must be
refinement-generable, else
bynk.history.not_generable. - A history property carries no
as— it runs in-process against the real handlers, on the generative, flake-free tier (tiers arecase-only). Capability seams a driven handler calls are still recordable and stillstub-stubbable, so observation and test doubles compose inside a driven run. - A history property that merely restates a declared
invariant/transition(e.g.run.all((s) => s.new.balance >= 0)when the agent carriesinvariant nonneg: balance >= 0) re-checks a guarantee every reached state already has, and is flaggedbynk.history.restates_invariant(a conservative, syntactic check).
The bounded-reach ceiling. A history property is a runner sample over
bounded, generated runs — never a proof. Unbounded liveness (“eventually P” over
an infinite run) is deliberately not expressible: a bounded run can only witness
bounded reach. This is the design choice that keeps history properties cheap and
flake-free and out of model-checking territory. An always-on-every-path guarantee, if
you need one, belongs to a policy / system-tier guarantee, not a history property.
On failure a history property reports the run count, the run’s root seed, and a
shrunk minimal sequence with a reproduce line — see
bynk.history.* errors. Single-agent
histories are the v1 surface; multi-agent / cross-context protocols are a named
follow-on.
Contracts — requires / ensures
Section titled “Contracts — requires / ensures”A contract is the invariant predicate attached to a function. Between a pure
function’s return type and its body, declare any number of named requires
(preconditions) and ensures (postconditions):
commons commerce.money
fn discount(p: Int, pct: Int) -> Int requires p_nonneg: p >= 0 requires pct_in_range: pct >= 0 && pct <= 100 ensures never_above: result <= p ensures never_negative: result >= 0{ p - (p * pct) / 100}requires <name>: <pred>is a precondition over the parameters.resultis not in scope (bynk.contract.result_in_requires).ensures <name>: <pred>is a postcondition over the parameters andresult— the return value (the awaited element for anEffectreturn). Outside anensures,resultis an ordinary identifier.- Each predicate is the same predicate surface as a
case, aproperty, or aninvariant: a pureBoolwithimplies,is, operators, and pure methods — no effects, capabilities,expect, orVal(bynk.contract.impure_predicate,bynk.contract.not_bool).
Checked at two points, for free. A contract needs no test to run:
- At every call in the dev/test build, a call-site guard checks each
requireson entry and eachensureson exit, throwing a contract failure that names the clause and the offending arguments/result. The guard is stripped from the deploy build (bynkc compile) — contracts add no production cost and never change production behaviour. - By the runner. For every contracted function reachable from a test target,
the runner generates arguments over the parameter domains (the same engine
for alluses — boundary-inclusive, seeded, shrinking), filters them by therequires(exactly as afor all … wheredoes — inputs failing a precondition are discarded), calls the function, and checks theensures. A failure reports the case count, the seed, and a shrunk counterexample with the same reproduce line apropertygives. A contract is a property that is always on.
ensures vs property. A claim about one result belongs in ensures —
checked everywhere and generated for free. A property earns its keep only when
the claim is relational or spans calls (monotonicity, a round-trip) — which no
per-call postcondition can express. A case/property that merely restates a
contract already declared at the source is redundant and flagged
bynk.contract.restated_by_test (a conservative, syntactic check).
Step invariants — transition
Section titled “Step invariants — transition”Where an ensures constrains one function call and an invariant constrains one
committed state, a transition constrains the move between two committed
states — declared on the agent, over the old/new state pair:
agent Order { key id: OrderId
store status: Cell[OrderStatus] = Pending
transition paid_is_terminal: old.status is Paid implies new.status is Paid
on call pay() -> Effect[()] { status := Paid () }}A transition is checked at the commit boundary, from the second commit
onward (the genesis commit has no old and is skipped), so — like an invariant —
it is carried by the agent and inherited by every case for free, at every tier;
you never write a test for it. It is not attacked by the runner: a fabricated
agent state is valid but not necessarily reachable, so behavioural generation over
transitions is a runner-driven handler-sequence concern, not value fabrication.
Full reference: Agent invariants → Step invariants.
See bynk.transition.* errors.
Observation — expect Cap.op called …
Section titled “Observation — expect Cap.op called …”Where the rungs above assert over values and state, observation asserts over
interaction: that the unit under test called a capability, with what arguments,
how many times, and in what order. Because a capability is injected at a known
seam, its calls are recorded automatically in the test build — a
pure-observation case needs no stub or setup at all:
suite orders { case "an oversized order is rejected and logged" { let r <- place.call(50000) expect r is Err(_) expect Logger.log called once with msg == "rejected: amount too large" expect Store.put never called -- a rejected order writes nothing }}The subject is a Cap.op reference — the capability and one of its operations,
named, not called (no argument list). The sugar forms are:
| Form | Holds when |
|---|---|
expect Cap.op called | at least one call |
expect Cap.op never called | zero calls |
expect Cap.op called once | exactly one call |
expect Cap.op called <n> times | exactly <n> calls (<n> an integer literal) |
expect Cap.op called with <pred> | at least one call whose arguments satisfy <pred> |
expect Cap.op called <n> times with <pred> | exactly <n> calls, and they match |
expect A.op before B.op | both occurred, and the first A.op precedes the first B.op |
A with predicate is the ordinary predicate surface with the operation’s
parameters in scope by their declared names (Logger.log(msg: String) → msg), so
with msg == "…" reads directly; it must be pure Bool.
For anything the sugar does not cover, the escape hatch binds the recorded calls as an ordinary value:
let calls = trace(Logger.log)expect calls.length() == 2expect calls.all((c) => c.msg.length() > 0)trace(Cap.op) yields a List of per-operation call records in call order — each
record’s fields are the operation’s parameters ({ msg: String } for Logger.log)
— so it is asserted with the List surface you already know (length(), all /
any, indexing). There is no test-only iteration construct: “for every recorded
call …” is calls.all((c) => …).
Recording is emitted only under bynkc test; the deploy build calls the seam
directly, so observation adds no production cost. Observation is scenario-specific
— a claim about one case; a universal guarantee (“every payment audits, on every
path”) is a policy, not a test.
System tests — a flow across Workers
Section titled “System tests — a flow across Workers”A case as system exercises a flow across several contexts, each stood up as
the Worker it actually deploys as — so the real cross-context wire (serialise →
JSON → deserialise → structural projection) is under test, which the in-process
tiers never touch. Its participants are inferred from the unit under test’s
consumes graph, so there is nothing to wire by hand:
suite checkout as system { case "small order authorises across the wire" { let r <- shop.orders.place(100) expect r is Ok(_) }}- A case calls into a participant by qualified name —
shop.orders.place(100)(a service) — exactly as a cross-context caller would. The call travels a simulated Service Binding into the target Worker; any further cross-context calls it makes (e.g.orders → payment) cross the wire too. - The inferred set must span at least two contexts (the target and a consumed
one); otherwise
bynk.tier.system_needs_wire. The closure underconsumesis derived automatically, so a consumed context can never be left unwired. - A
stubclause is legal at every tier,systemincluded (it overrides one seam); what a tier controls is the default provision.
Cross-context capabilities (given B.Cap) are wired as in production: the
provider is instantiated locally in the consumer Worker (v0.15 model A1).
Agents (Durable Objects) work too: a participant’s agents are backed by
in-memory Durable Object instances — same key, same instance within a case;
state starts empty and is fresh per case. See
Test tiers.
bynkc test runs system cases in plain Node alongside the in-process tiers — it
compiles the inferred participants in workers mode under out/workers/, stands
them up in-process, and routes the real wire between them. No
wrangler/miniflare needed.
Running
Section titled “Running”bynkc test .bynkc test compiles the project (including tests), type-checks the output with
tsc, and runs it with Node — both must be on your path. --no-run emits the
TypeScript without running it. Exit code is non-zero if any test fails.
Debugging under Node (--inspect)
Section titled “Debugging under Node (--inspect)”bynkc test --inspect launches the test runner under Node’s inspector
(node --inspect-brk) and prints an inspector URL:
bynkc test . --inspect# → Debugger listening on ws://127.0.0.1:9229/…Attach any JavaScript debugger to that URL (VS Code’s built-in Node debugger,
Chrome DevTools, …). Breakpoints set in your .bynk sources bind and pause
there — the compiler emits source maps (since v0.68) and, under --inspect, runs
the emitted TypeScript directly so those maps resolve breakpoints back to .bynk.
This requires Node ≥ 22.6 (it relies on Node’s TypeScript type-stripping) and
does not run tsc. Breakpoints bind on the statement you click — both in the code a
test exercises and inside the test body itself (since v0.70 maps test-case and
handler bodies per-statement). A one-click VS Code launch is in progress.
Machine-readable output (--format json)
Section titled “Machine-readable output (--format json)”bynkc test --format json emits a single pinned JSON document of results
instead of the human ✓ / ✗ output — one suites array of { name, kind, cases },
each case { name, outcome, message?, location? } with outcome one of "pass"
or "fail". A project that doesn’t compile yields error.kind == "compile"
(the diagnostic lines); a runner that crashes mid-stream yields
error.kind == "runtime" with the observed prefix and captured stderr.
Add --no-run for discovery: bynkc test --no-run --format json lists every
suite and case without running them — a pure compile (no tsc, no Node, no
out/ written). Each case carries outcome: "discovered" and its declaration
location (the case "…" name). The suite/case names match a normal run’s, so a
consumer can list tests first and fold in pass/fail from a later run. This is how
the VS Code Test Explorer populates its tree before you run anything.