Testing

Native specs run your app against a simulated DOM using the same reactive engine as the browser build. They let you check rendered values, state lifetime, requests, and update work. You control task results and timer ticks explicitly, so a test does not need a network connection or a delay to exercise those paths.

For a checkout-based app, build for your machine and run its specs:

roc build --target=arm64mac --output=/tmp/app examples-web/my-app/main.roc
python3 scripts/spec_driver.py /tmp/app examples-web/my-app/specs

The driver prints a result for each case and a summary. Exit code 0 means every selected case passed. Failures include the assertion line:

TEST FAILED at line 4: locator did not resolve to one element

Targets: arm64mac, x64mac, arm64musl, x64musl.

Each *.scm file is one data-only S-expression test case. Put cases under the app’s specs/ directory; the driver discovers them recursively, sorts their relative paths, and runs each against a fresh app process. It supports bounded parallelism, glob filters, deterministic sharding, per-case timeouts, and fail-fast scheduling.

(test "checkout succeeds"
  (steps
    (fill (label "Email") "team@example.com")
    (click (role button :name "Place order"))
    (expect-visible (text "Order confirmed"))))

Locators are semantic

Locators identify elements by role, name, label, text, or an explicit test id. The native host models a subset of browser naming and roles; a matching locator is not an accessibility audit.

LocatorExample
Role and accessible name(role button :name "Send invite")
Associated label(label "Invite email")
Exact visible text(text "Submit status: idle")
Test id(test-id "traffic-chart")

A locator must resolve to exactly one element. Matching several is an error (locator matched 2 elements). Repeated controls may legitimately share a name; use a unique test id when the role and name cannot distinguish the target.

Html.section, Html.form_label, Html.link, and input helpers provide name metadata for these locators. Give controls useful names and check their browser accessibility separately.

For repeated rows, derive a unique test id from the row key:

Html.checkbox_attrs("Read", read, [Html.test_id("book-${id}")], msg)

Actions

(click (role button :name "Save"))
(real-click (role button :name "Open note"))
(fill (label "Message") "draft")
(check (label "Accept terms"))
(uncheck (label "Accept terms"))
(change (label "Plan") "growth")
(select-option (label "Plan") "enterprise")
(submit (role form :name "Signup form"))
(focus (label "Message"))
(blur (label "Message"))
(key-down (label "Command search") "Enter" false)
(pointer-down (role region :name "Release card"))
(pointer-up (role region :name "Release card"))
(pointer-enter (role region :name "Drop target"))
(pointer-leave (role region :name "Drop target"))
(composition-start (label "Message"))
(composition-end (label "Message"))
(custom-event (test-id "traffic-chart") "chart-select" "point-1")

click versus real-click. click dispatches directly to the target’s own click binding. real-click dispatches pointerdown → pointerup → click through the full propagation path — capture, bubble, self, and stop policies — and runs default actions. Use real-click for nested controls inside clickable or draggable parents, and for submit buttons inside forms.

key-down takes the key name and a shift-key boolean. custom-event sends its final argument as event.detail.

Checkboxes use the checked-change path, so use check / uncheck — or real-click, which runs the browser’s default toggle action. A bare click on a checkbox with no click binding fails with target has no click binding.

Assertions

(expect-visible (role heading :name "Team Checkout"))
(expect-absent (role region :name "Queue Widget"))
(expect-text (test-id "submit-status") "Submit status: sending")
(expect-value (label "Invite email") "ops@example.com")
(expect-attr (label "Invite email") aria-invalid "true")
(expect-no-attr (label "Invite email") aria-invalid)
(expect-checked (label "Accept terms") true)
(expect-disabled (role button :name "Send invite") true)
(expect-updates (label "Message") 2)

expect-updates asserts how many times a specific element was patched, summed across its text, value, checked, and disabled sinks — a direct way to prove that an unrelated change did not touch something.

Give text that changes a stable locator so failures can show the actual value:

Html.paragraph_s_attrs(status, [Html.test_id("submit-status")])

(expect-text (text "Done") "Done") only proves that text exists. Use expect-visible for that intent, or expect-text with a stable test id to compare a changing value. For a container without its own text, expect-text compares concatenated descendant text. expect-visible checks presence in the native model; it does not evaluate CSS visibility.

Tasks and timers

Tasks and timers are driven by the spec, not by a clock. There is no sleeping and no polling.

(expect-pending-task "form-submit" 1)
(resolve-task "form-submit" "queued")
(reject-task "lookup" "offline")
(resolve-stale-task "lookup" "late")
(expect-canceled-task "lookup" 1)

(tick-interval 1000)
(tick-interval-if-active 1000)
(expect-interval 1000 1)

(expect-cleanup "live search panel cleanup" 1)

resolve-stale-task delivers a result for a canceled request. It requires a previous cancellation; without one the spec fails. For a search that starts a request on each changed input, check that the older result leaves the loading state intact:

(fill (label "Search") "ro")
(fill (label "Search") "roc")
(resolve-stale-task "lookup" "results for ro")
(expect-text (test-id "search-status") "Search status: loading")
(resolve-task "lookup" "results for roc")
(expect-text (test-id "search-results") "Results: results for roc")

expect-pending-task checks an absolute count, not a delta. To prove a second interaction did not start or replace a request, check both the pending count and expect-canceled-task.

Task names come from Signal.fake_task(name, ...) or, for HTTP, from Http.request_task(purpose) — which registers as http:send:<purpose>.

Browser environment

Use setup values to test startup with a saved draft, a deep link, or an offline environment. For example, an app that leaves the initial URL and storage intact can assert:

(test "restored navigation"
  (setup
    (initial-location "/article/keyed-lists")
    (initial-visibility hidden)
    (initial-online offline)
    (local-storage "conduit.jwt" "test-token")
    (session-storage "draft" "hello"))
  (steps
    (expect-current-location "/article/keyed-lists")
    (expect-local-storage "conduit.jwt" "test-token")
    (expect-session-storage "draft" "hello")))

The forms in (setup ...) run before the first render. Within (steps ...), use navigate, history-back, history-forward, set-visibility, and set-online to change the environment. Storage assertions can check values or absence with expect-no-local-storage and expect-no-session-storage.

A full navigation test:

(expect-document-title "Home")
(expect-visible (text "You are home"))
(click (role link :name "Go to About"))
(expect-current-location "/about")
(expect-document-title "About")
(history-back)
(expect-current-location "/")
(expect-visible (text "You are home"))
(history-forward)
(expect-current-location "/about")
(navigate "/nowhere")
(expect-document-title "Not found")

Work budgets

Work metrics help catch regressions that leave the visible result unchanged, such as rebuilding rows during a reorder.

Call mark-metrics, perform an action, then assert exact or maximum deltas:

(mark-metrics)
(click (role button :name "Reverse rows"))
(expect-metric-delta rows_reused 4)
(expect-metric-delta rows_created 0)
(expect-metric-delta rows_removed 0)
(expect-metric-delta signal_record_table_rebuilt 0)

This example assumes four live rows. It checks that reversing them preserves their scopes and does not rebuild the signal record table. Use the row count and work bounds appropriate to your fixture.

Commonly useful metrics:

MetricMeaning
derived_calls_into_rocderived signal transforms that ran
rows_created / rows_removed / rows_reusedkeyed-row churn
scopes_created / scopes_disposedscope lifecycle
events_processedevents dispatched into the graph
propagation_prunespropagations stopped by is_eq
stale_task_results_ignoredsuperseded task results discarded
active_intervals_syncedtimer bookkeeping
retained_alloc_deltaretained Roc allocations
host_retained_bytes_deltaretained host bytes

The authoritative list is in src/spec/spec_runner.zig.

Assert structural outcomes exactly: rows created, removed, and reused, and scopes created or disposed. Bound incidental engine work with expect-metric-delta-at-most. Choose a bound from the work the interaction should require, then compare small and large fixtures when testing scaling. Copying an observed number into a test without that reasoning can preserve an existing regression.

derived_calls_into_roc counts derived evaluations, while dirty_source_roots counts changed sources. One source can wake many transforms. propagation_prunes records equality cutoffs; interpret it alongside the graph and visible result, since a low count alone does not establish an equality bug.

Do not pin patches_emitted in semantic specs: it combines unrelated command kinds. Use row and scope counters for structural behaviour and benchmark telemetry to track overall command traffic.

For a retained-allocation delta that should not be there, rerun the built native app with --trace-allocations:

.test-out/bin/signals-my-example --trace-allocations examples-web/my-example/specs/case.scm

The host writes an allocation checkpoint after mount and after every spec command. Each summary separates Roc allocations freed since the previous checkpoint from newly allocated blocks that are still live, and reports host-only live allocation/byte deltas alongside them. Roc backing blocks are subtracted from the host allocator totals, so this immediately distinguishes Roc retention from growth in host-owned collections. The following cohort lines group surviving Roc blocks by requested size, runtime debug phase, and native return address. This makes repeated growth attributable: reproduce the same interaction several times, find the cohort that grows each time, then symbolize its caller address with the platform debugger or addr2line against that app binary. Phase values come from the debugPhase sites in src/signals/engine.zig and the host-value boundary sites in src/native_host.zig.

Tracing is opt-in because retaining provenance and scanning live allocations at every checkpoint is diagnostic work. Ordinary specs and benchmarks continue to pay only for the existing allocation ledger and counters.

roc_metric_live is the independent alloc-minus-dealloc counter. It should equal the ledger’s roc_live block count at every checkpoint; a mismatch means the instrumentation itself is observing an ownership boundary at the wrong time.

In the browser, the runtime’s telemetry option emits an allocation_checkpoint after each applied command batch when the allocation exports are available. Meaningful Roc allocation counts and phase-and-size cohorts require a host built with the allocation ledger enabled, such as Debug, ReleaseSafe, or the instrumented benchmark host. Ordinary ReleaseSmall and ReleaseFast hosts omit that ledger; zero debug counts from those builds are not evidence that no Roc allocations remain. See Contributing for host builds and the instrumented benchmark workflow.

Teardown is also a leak gate. Native specs fail if either the Roc ledger or the host debug allocator is non-empty after the runtime is dismantled. The Wasm mount harness checks that the HostValue registry is empty after unmount and also checks the exported Roc allocation count and byte total. Those latter checks establish ledger balance only when the host has the ledger enabled.

What belongs where

Native specs are the right home for app semantics: what the user sees, what the app requests, what state survives. They are not a browser.

The JavaScript contract tests (zig build run-test-browser) and Node mount harness check the JS/Wasm protocol and runtime behaviour with a DOM double. They do not render a page in a real browser.

Use real-browser tests for CSS layout, focus and selection, IME interaction, keyboard navigation, network integration, and accessible names and relationships. Include manual accessibility checks where automation cannot establish usability. A native pass establishes behaviour within the native model only.

Running suites

Individual app:

roc build --target=arm64mac --output=/tmp/app examples-web/my-app/main.roc
python3 scripts/spec_driver.py /tmp/app examples-web/my-app/specs

Repository suites:

python3 scripts/test.py native --native always   # all native specs
python3 scripts/test.py roc-check                # type-check every app
python3 scripts/test.py roc-test                 # run every app's `expect` tests
python3 scripts/test.py wasm                     # build every app to wasm
python3 scripts/test.py browser                  # JavaScript contract tests
python3 scripts/test.py                          # everything

Next

Under the Hood — what actually crosses the WebAssembly boundary.