Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

Test framework#

Harn includes a built-in test runner invoked via harn test.

Running tests#

harn test path/to/tests/         # run all test files in a directory
harn test path/to/test_file.harn # run tests in a single file

Tests are ordinary unprivileged modules by default. A Rust-hosted service may use harn test --trusted-host-dispatch path/to/test_file.harn to compile the test and its private import graph with the same explicit privileged-wire authority as its host-selected production route graph. The flag does not change the authority of modules loaded by ordinary Harn imports.

Test discovery#

The test runner scans .harn files for pipelines whose names start with test_. Each such pipeline is executed independently. A test passes if it completes without error; it fails if it throws or an assertion fails.

pipeline test_addition(harness: Harness) {
  assert_eq(1 + 1, 2)
}

pipeline test_string_concat(harness: Harness) {
  const result = "hello" + " " + "world"
  assert_eq(result, "hello world")
}

Pipelines can also opt in with @test, including table-driven cases. A @test(cases: [...]) attribute creates one independent test case per row. Each row must be a dict with a unique string name and an args list whose length matches the pipeline parameter count. Reports and filters use the display name pipeline[row].

@test(cases: [
  {name: "positive", args: [2, 3, 5]},
  {name: "negative", args: [-2, 1, -1]},
])
pipeline add_case(harness: Harness, left, right, expected) {
  assert_eq(left + right, expected)
}

Reusable fixtures#

@test_fixture(scope: file|case) marks a zero-argument function with an explicit return type as reusable test setup. Select it by name with @test(fixture: fixture_name); the fixture value is injected as the test pipeline's first parameter and table-row args supply the remaining parameters.

@test_fixture(scope: file)
fn fixture() -> dict {
  return {prefix: "user", rows: []}
}

@test(
  cases: [
    {name: "alice", args: ["alice", "user:alice"]},
    {name: "bob", args: ["bob", "user:bob"]},
  ],
  fixture: fixture,
)
pipeline test_query(
  harness: Harness, fx: dict, input: string, expected: string,
) {
  fx.rows.push(input)
  assert_eq("${fx.prefix}:${input}", expected)
}

A file fixture runs once for the selected cases that reference it. Its return value must be isolate-safe data: scalars, bytes, ranges, and nested lists, dicts, sets, structs, enums, or pairs. Each case receives an isolated copy-on-write clone, so mutation in one case cannot leak to a sibling. Execution-bound values such as closures, channels, atomics, task/resource handles, generators, streams, iterators, or harness capabilities are rejected as one file-level setup failure.

A case fixture runs inside each case's fresh VM immediately before the test pipeline. Use it for resources or other execution-bound values. The fixture and test share one timeout and one pipeline lifecycle. VM/resource drop is the teardown contract for both scopes; there is no separate teardown hook.

If file fixture setup fails, the runner emits one named file-level failure, suppresses only the cases that reference that fixture, and continues other files and unrelated tests. --fail-fast stops before case scheduling instead. Fixture declarations, references, scopes, arity, row shape, and row names are validated during discovery with source locations.

Assertions#

Three assertion builtins are available. They can be called anywhere, but they are intended for test pipelines and the linter warns on non-test use:

FunctionDescription
assert(condition, message?)Throws if condition is falsy
assert_eq(a, b, message?)Throws if a != b, showing both values
assert_ne(a, b, message?)Throws if a == b, showing both values

All three accept an optional custom message. If message is omitted, nil, an empty string, or the literal string "null" (the common result of json_stringify-ing a value that turned out to be nil), the assertion falls back to its default message instead of throwing that uninformative value verbatim.

Captured output#

log, print, println, and related output builtins write into a per-case buffer rather than directly to the terminal. A passing test stays quiet by default; a failing test always prints its buffered output alongside the failure. Pass --verbose to see a passing test's captured output too. --json-out and --junit reports include it under captured_output (JUnit: <system-out>) whenever it is non-empty.

Mock LLM provider#

During harn test, the HARN_LLM_PROVIDER environment variable is automatically set to "mock" unless explicitly overridden. The mock provider returns deterministic placeholder responses, allowing tests that call harness.llm.call, harness.llm.stream, or harness.llm.stream_call to run without API keys.

CLI options#

FlagDescription
--filter <pattern>Only run tests whose names contain <pattern>
--verbose / -vShow per-test timing and detailed failures
--timingShow per-test timing and summary statistics
--timeout <ms>Per-test timeout in milliseconds (default 30000)
--parallelRun user tests in a bounded pool, or conformance tests in isolated worker processes
--fail-fastStop scheduling new tests after the first failure; already-running parallel tests finish
--shard-index <N> / --shard-total <N>Select one deterministic user-test or conformance shard
--timing-environment <name>Stamp the enforcing environment into a user-test JSON receipt
--timing-baseline <path>Balance user-test shards from a matching Harn receipt and enforce absolute case-cost regressions
--junit <path>Write JUnit XML report to <path>
--recordRecord LLM responses to .harn-fixtures/
--replayReplay LLM responses from .harn-fixtures/