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:
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:
- Pass — the program runs to completion and exits
0with nothing on stdout. A silent test has passed. Only stdout is inspected: what the program writes to stderr (eprintln) is diagnostic output and doesn’t count, so print progress there if you want it. - Fail — the first failing assertion prints
ASSERTION FAILED: <msg>(and, forassert_eq_*, the expected and actual values) and the program exits non-zero. There’s no “collect all failures” — the first one stops the run.
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:
- Only what you own.
dissolve()is your code; it cleans your child, your directory, your lock file. Nothing cleans up on your behalf. - Assertions belong in
main. An assertion that fails inside a helperfnor a locus method has nomainframe to return through, and ends the process on the spot without the dissolve cascade. Keep the assertions that guard owned resources at the top level. kill -9is different. If something SIGKILLs the test process, nothing runs — notdissolve(), not an atexit hook. That’s the platform, not Hale; an external reaper is the only answer.
spec/testing.md § “What runs after a failed assertion” is the
normative version of this.
Running the suite
Section titled “Running the suite”hale test # discover + run every *_test.hl under the cwdhale test tests/ # ...under a directoryhale test -run concat # only files whose path contains a substringhale 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.