Code that only compiles proves little. That is true of examples, and equally of tests, benchmarks, fuzz targets and fixtures. Anything in the repository that is not run goes stale without anyone noticing, and is found broken on the day it is needed.

The rule is that every Roc program in the repository runs in pull-request CI, even if only briefly. This chapter says what runs, how much of it, and how the tests are organised.

Start with expect tests for concrete behavior and integration cases for the program boundary. Use roc-fuzz alongside them to check rules over generated inputs, preserve named regressions and measure memory behavior. Pure Roc code needs both value correctness and allocation properties: returning the right value does not show whether an update reused storage or copied it. Keep these checks fast on pull requests, and explore new inputs in separate campaigns.

Risks and controls for test evidence§

Compilation alone can leave examples or error paths broken, while a long campaign in routine CI can make feedback too slow to use. Keep fast checks meaningful and put sustained searches on their own schedule. Build once, run recorded success and failure cases, and exercise bounded fuzz/benchmark smoke modes. Require expected outputs and exits, report skips explicitly, retain command diagnostics and stop hung child trees. The driver tests these failure states. A smoke pass establishes that the selected workload runs; it does not establish search depth, performance or complete test coverage.

What runs on every pull request§

CheckWhat it runs
Formattingroc fmt --check over the repository’s Roc sources
Unit testsroc test on the package or platform root
ExamplesEvery example, built once and run against its recorded cases
Integration testsThe test driver, over its full list of cases
Allocation checksAllocation properties and recorded examples, checked against reviewed ceilings or accepted exact baselines
BenchmarksA smoke run: each benchmark builds and completes a small, bounded iteration
Fuzz targetsA smoke run: each target builds, checks its declared examples and replays a small committed corpus
ScriptsThe expect tests of the modules under scripts/src/

The check task runs all of these, so a contributor runs the same set with blueprint run check before opening a pull request.

Heavy runs stay out of pull-request CI§

A long fuzzing campaign or a full benchmark run never runs on a pull request, including a release dry run. They run in separate scheduled or manually dispatched jobs, as described in Fuzzing and Benchmarks.

What pull-request CI owes them is a smoke run. A benchmark that has not been built since the API it measures changed will not run on the night it is scheduled. Give each benchmark and fuzz target an explicit smoke mode, with a small input or iteration count and at most one second of workload execution per target. This budget excludes provisioning and compilation; it is not a one-second limit for the whole CI job. Keep the committed smoke corpus small enough to replay within that budget. Build once and reuse the executable across cases.

The smoke mode exercises startup and the real operation or property, then exits normally. A benchmark smoke run checks execution, not performance: it produces no baseline comparison or performance regression verdict. A fuzz smoke run replays known inputs; where the engine itself needs exercising, add a tiny, bounded run without turning the check into a campaign. A passing smoke run makes no claim about sustained search coverage.

Workload bounds and safety deadlines serve different purposes. Set a small iteration/input budget so normal execution completes within the smoke budget, and give the driver a separate hard deadline to stop hangs and child processes. A crash, wrong result or exceeded deadline fails the smoke check; stopping a process at the deadline does not establish success. Preserve compiler and workload diagnostics even on successful commands, with warnings nonfatal by default. Print explicit skips with reasons; skipped work is not passing compatibility evidence.

Keep the long-running task out of blueprint run check and every PR release dry-run path. Moving it into another job is insufficient if that job still runs on every pull request: use schedule/manual triggers and explicit event guards. The routine PR aggregate requires the build and smoke jobs, not a skipped campaign job.

Build with --opt=dev§

Build and run with --opt=dev unless there is a reason not to. It compiles quickly, which is what a test run spends most of its time on, and the checks in this chapter ask whether the program is correct, not how fast it is.

Use --opt=speed only where the optimised build is the thing under test: a benchmark’s measured run, a long fuzzing campaign, or a release artifact that ships optimised. A benchmark’s smoke run in pull-request CI uses --opt=dev like everything else.

Unit tests§

Run roc test once, on the root: package/main.roc for a package, platform/main.roc for a platform. Roc runs the expect tests of every module the root imports, transitively, so there is no need to list files or to run the command per module. A module that the root does not reach is either dead code or missing from the package, and either is worth knowing.

A repository with several roots runs roc test once on each.

Integration tests§

An integration test runs a built program and looks at what it does: its output, its exit code, the files it writes. Organise these around an external driver and a list of cases, not as one program per test.

One build, many cases
Figure 1. One build, many cases

The driver is a Roc script under scripts/, and the cases live under tests/. The expected-output specification for examples, described in Record expected output, is one such list of cases run by the same driver.

Skip a broken case, visibly§

Sometimes a case is broken for a reason that cannot be fixed today: a compiler bug, a dependency that has not been released, a platform limitation. Deleting the case loses it, and leaving it failing blocks every pull request. Skip it, and make the skip impossible to forget.

The same applies outside the harness: an example, benchmark or fuzz target that is switched off says why, in a TODO with an issue link, at the place it is switched off.

Report progress and results§

A harness is read far more often than it is written, usually by someone waiting for it or trying to find out what failed. Its output is part of its job.

What a run looks like
Running 20 cases
 [ 1/20] parse-empty-input ............ PASS      12ms
 [ 2/20] parse-nested-lists ........... PASS      48ms
 [ 3/20] render-wide-table ............ FAIL   1_204ms
 [ 4/20] nested-tags .................. SKIP          compiler bug, roc-lang/roc#1234
 ...
 [20/20] rejects-missing-file ......... PASS       9ms

Failed
  render-wide-table: expected exit code 0, got 1
    tests/cases/render-wide-table

Skipped
  nested-tags: compiler bug, roc-lang/roc#1234

Summary: 18 passed, 1 failed, 1 skipped in 10_312ms
Slowest: render-wide-table 1_204ms, build 6_870ms

Measure allocations§

Memory behavior is a quality of Roc code in its own right, and it is invisible unless it is measured. A change that keeps a reference alive a little longer can turn an in-place update into a copy of a whole list, on every call. The result is still correct, every test still passes, and the package is now several times slower for its users.

Use roc-fuzz as a test platform for pure package code. Its host measures allocations, reallocations, deallocations, requested bytes and live/peak memory. Test the hot paths and operations users call in a loop, including empty, boundary-sized, uniquely owned and shared inputs.

Allocation properties and exact baselines§

Use two complementary checks:

These checks have different review rules. A ceiling fails only when the count exceeds the bound; lower it when an improvement should be protected. roc-fuzz’s baselines match exactly, including example inventory, revisions, actual input digests, measurement regions, metric version and statistics. Reductions also require review and explicit acceptance. A compiler improvement should therefore update a baseline deliberately, rather than silently changing what CI accepts. Commit baselines for each supported host and named profile; record the compiler and build options with the evidence.

check verifies examples and accepted baselines without exploration. A missing entry is reported as unbaselined and permits normal execution; it is not allocation regression protection. Review the declared inventory and measurements, run accept explicitly, and commit the resulting baseline before treating checks as evidence of a protected allocation contract. CI runs check or run, never accept.

Allocation evidence depends on the measured scope and storage shape. roc-fuzz measures the property after input generation/decoding and before adapter teardown; named regions narrow that scope further. Keep aliases alive when testing shared storage, and observe returned values so the intended operation remains relevant. A logically equal value can have different capacity, sharing or slices. Preserve original witness bytes for storage-sensitive failures: reconstructing a value in an expect test may lose the allocation behavior that exposed the bug.

Counts are a steadier PR signal than wall-clock timings, but they depend on the compiler, host, build and lifetime of values. Review baseline diffs alongside correctness results, and use a deliberate failing assertion or changed baseline to verify that the harness detects a regression. A pass covers the checked examples and explored inputs; it does not prove an allocation bound for all inputs. roc-fuzz also supports logical trace rules (Before, Count and Forbidden) for ordering and event invariants. These are logical observations; wall-clock performance belongs in the separate benchmark workflow.

Correctness and memory properties with roc-fuzz§

Use property-based tests for invariants across inputs, especially parsers, encoders, data structures and arithmetic on sizes or indexes. roc-fuzz combines correctness, allocation and trace checks with coverage-guided exploration in a self-contained Roc target. The target imports the package; the shipped package has no dependency on roc-fuzz.

PartRole
GeneratorProduces typed Roc values to try: numbers, strings, lists, records, or shapes built from them
OperationCalls the package code under test
PropertyChecks value correctness, memory behavior or a logical invariant
ExamplesNamed cases and saved witnesses that run independently of corpus retention

A pure function needs no external state to recreate. Useful properties include round-trip encoding, formatting idempotence and agreement with a simple reference implementation. Preserve failing input bytes and the target/compiler/build configuration, especially for storage-sensitive memory checks. Coverage guides exploration toward new branches; saved witnesses and named examples preserve regressions independently of the evolving corpus.

What a target looks like§

app [target] {
	fuzz: platform "https://github.com/lukewilliamboswell/roc-fuzz/releases/download/<version>/<hash>.tar.zst",
	pkg: "../../package/main.roc",
}

import fuzz.Fuzz
import pkg.Stack

# After pushing an item, popping must return that item and the original stack.
test : List(U8) -> Fuzz.Outcome
test = |initial| {
	item = 0
	pushed = Stack.push(initial, item)

	match Stack.pop(pushed) {
		Ok({ value, rest }) if value == item and rest == initial => Fuzz.keep
		Ok(_) => crash "pop did not undo push"
		Err(Empty) => crash "the stack was empty after push"
	}
}

target = Fuzz.target_with({
	examples: [
		Fuzz.example({ name: "empty", revision: "1", make: || [] }),
		Fuzz.example({ name: "nonempty", revision: "1", make: || [1, 2, 3] }),
	],
	name: "stack-push-pop",
	generator: Fuzz.list(Fuzz.u8, 8),
	test,
	show: |initial| Str.inspect(initial),
})

The target depends on the working tree by relative path, like every other Roc application in the repository, and on a roc-fuzz release by URL. It lives under fuzz/, outside the package root, so it is never bundled. Use the content-addressed platform URL and compatible compiler documented in the release notes.

Build once, then use the commands embedded in the executable:

roc build --fuzz fuzz/stack/main.roc --output=stack-fuzz
./stack-fuzz check
# After reviewing the observations, explicitly accept and commit the baseline.
./stack-fuzz accept
./stack-fuzz check
# Local exploration; the default run searches for 60 seconds after checking.
./stack-fuzz run

check runs declared examples and baseline checks without searching. run checks them before exploring, and skips exploration when those checks fail. Use run --report-dir=PATH for campaign evidence; the report directory must be absent or empty. Bare invocation also runs the default workflow, so PR tasks must select check and bounded replay explicitly rather than accidentally starting a 60-second search. The default baseline is <declared-target-name>_baseline.json in the working directory; use --baseline=PATH and --profile=NAME when needed.

For an allocation-aware target, use an effectful property and Fuzz.target_with!. For example, popping a stack can check the returned length while asserting that it performs no allocations or reallocations:

test! : List(U8) => Fuzz.Outcome
test! = |initial| {
	popped = Fuzz.expect_allocs_at_most!(0, || Stack.pop(initial))
	match popped {
		Ok({ value: _, rest }) if rest.len() + 1 == initial.len() => Fuzz.keep
		Err(Empty) if initial.is_empty() => Fuzz.keep
		_ => crash "pop returned the wrong length"
	}
}

Pass test! in place of test to Fuzz.target_with!, keeping the generator, examples and display function. Replace the measured operation and property with the package’s actual memory contract. The roc-fuzz quality guide explains named measurements, baselines, trace rules and report fields.

How targets are managed§

Review properties when APIs or implementations change. Strengthen invariants and generators as the input domain becomes clearer; add named examples for newly discovered failures.

Validate preconditions for decoded examples as well as generated values; generator bounds express search bias, not input validation. Report attempted, accepted and rejected inputs. Exploration with zero accepted inputs is inconclusive and unsuccessful. Distinguish the outcome from allocation validity, trace-rule results and capture completeness in the evidence.

A weak property finds nothing, and a passing run is not a proof. The roc-fuzz manual covers writing generators and properties, running campaigns, and reading a saved failure.

Formatting§

Run roc fmt --check in pull-request CI, over every Roc source the package compiler builds, and roc-stable fmt --check over the scripts, whose roc field another compiler’s formatter would rewrite. It fails the pull request that introduces unformatted code, which is the only moment at which fixing it is cheap.

Do not run it in nightly.yml. A new nightly can change the formatter, and a formatting difference says nothing about whether a release still works. The check would raise an alarm that no patch release answers. When a new formatter does change the output, the next pull request sees it, and reformatting is a small pull request of its own.