Testing

A test in Hale is just an ordinary program. There's no framework to learn, no annotations, no test classes — a test is a .hl file whose fn main() runs some…

A test in Hale is just an ordinary program. There’s no framework to learn, no annotations, no test classes — a test is a .hl file whose fn main() runs some assertions, and hale test finds and runs them.

Writing a test

Section titled “Writing a test”

Name a file *_test.hl and write assertions in main:

arith_test.hl
fn main() {
std::test::assert(2 + 2 == 4, "trivial arithmetic");
std::test::assert_eq_int(6 * 7, 42, "product");
std::test::assert_eq_str("con" + "cat", "concat", "string concat");
}

std::test ships three primitives, all written in Hale itself:

Assertion Passes when…
assert(cond, msg) cond is true
assert_eq_int(actual, expected, msg) actual == expected (with an expected / actual diff on failure)
assert_eq_str(actual, expected, msg) actual == expected (the same diff, quoted)

The contract is exit-code based, and it’s the whole model:

Because a test is an ordinary binary, you can also just run it directly: hale run arith_test.hl passes silently or prints the failure.

A failing test still cleans up after itself

Section titled “A failing test still cleans up after itself”

“The first failure stops the run” doesn’t mean the program is shot where it stands. A failing assertion prints its line, records the failure, and then main exits the way it always does — pools joined, bus drained, and every locus you let-bound in main dissolved in reverse order — before the process returns 1.

That matters the moment a test owns something real. Suppose a test starts a server under test and writes into a scratch directory:

locus Sandbox {
params {
dir: String = "";
pid: Int = -1;
}
dissolve() {
// Runs on every exit from main — including a failed
// assertion. Cleans exactly what this test created.
let _stop = std::process::run(f"sh\n-c\nkill {self.pid}; rm -rf {self.dir}")
or std::process::ProcessOutput { code: -1, signal: 0, stdout: "", stderr: "" };
}
}
fn fetch(url: String) -> String {
let resp = std::http::get(url) or { return ""; };
return std::str::from_bytes(resp.body);
}
fn main() {
let dir = f"/tmp/my_suite_{std::process::pid()}";
std::io::fs::mkdir(dir) or discard;
let srv = std::process::spawn("./build/server") or raise;
let sandbox = Sandbox { dir: dir, pid: srv.pid };
std::test::assert_eq_str(fetch("http://127.0.0.1:8080/health"), "ok",
"server is up");
}

If assert_eq_str fails, Sandbox.dissolve() still runs: the server is stopped and the scratch directory is gone. The next run starts clean. Put cleanup in a dissolve() and you never need a hand-rolled exit watcher.

Three edges are worth knowing:

spec/testing.md § “What runs after a failed assertion” is the normative version of this.

Running the suite

Section titled “Running the suite”
Terminal window
hale test # discover + run every *_test.hl under the cwd
hale test tests/ # ...under a directory
hale test -run concat # only files whose path contains a substring
hale test --json # machine-readable results (one record per file)
hale test -j 1 # one file at a time (default: one per core)

Each --json record carries file, status, elapsed_ms and — on a failure — message. The file is that test’s canonical path (absolute, symlinks resolved, no ..), whatever spelling you typed, so records from hale test --json and hale check --json join on it; the ok / FAIL lines you read on a terminal keep the spelling you typed.

hale test compiles each discovered file to a native binary and runs it, reporting which passed and which failed. It’s the same binary that hale build produces — there’s no separate test runtime.

Those binaries never land in your tree. Each hale test run makes one private directory under $TMPDIR (hale-test-<pid>-…, mode 0700), builds every test binary and its object files into it, and removes the whole directory when the run ends, pass or fail. The same holds for hale run and hale replay, which build into a directory of their own (hale-run-…, hale-replay-…). To keep a binary, use hale build ... -o <path>.

Files compile and run in parallel, one per available core unless you say otherwise with -j N (or HALE_TEST_JOBS=N). The report does not depend on it: the lines come out in sorted file order once every file is done, and whatever a test prints stays with that test. What parallelism does ask of a test is that it own what it touches — a test that listens on a fixed port or writes a fixed path under /tmp can collide with its neighbour. Take a free port and a per-run scratch directory instead.

Each test file also gets a vault of its own: hale test points std::secret at a fresh private directory (HALE_VAULT_DIR) for the file’s run and removes it afterwards, so a test that draws or stores a secret never touches yours, and never sees one you hold. Run with HALE_TEST_KEEP_VAULT=1 to keep each file’s vault and see where it is.

One property comes free with that binary: a test whose loci all run on the main scheduler is deterministic. No placement, no extra pools — then every publish and every delivery happens in the same order on every run, by construction (one pool is one consumer thread; handlers run to completion). A flaky single-pool test is never the scheduler’s fault: look for a real input — time, entropy, environment, the network — feeding the assertion instead. If you want the compiler to prove there isn’t one, mark the code under test @deterministic.

Testing what runs over time

Section titled “Testing what runs over time”

The assertions above check values. For a long-running locus, the property you want to hold isn’t a single value but an invariant — “debits always equal credits,” “the buffer never exceeds capacity.” Those are closures: you declare the invariant on the locus, and the runtime audits it as the program runs. Closures are part of Hale’s verification story, not its testing library — but they’re how you assert the things a unit test can’t reach.

The rest of the toolchain rides alongside: hale bench runs *_bench.hl benchmarks (zero-param bench_* fns, self-calibrated ns/op + allocs/op), hale verify is the CI gate (identical analysis to check, but any finding fails), hale fmt keeps everything canonical (--check in CI), and hale doc renders API references from /// comments. Everything ships in the one binary.