Testing For Dummies — The Axon Test Framework Manual

MANUAL NO. AX‑001 · REV 2026.08 · CLASSIFICATION: HARMLESS
Testing For Dummies: a crowd of yellow crash-test-dummy robots in a control room, one pressing a large red button on a caution-striped pedestal, with a stack warning light, scattered tools and wiring, and a banner reading Axon Test Framework Manual.

A field guide to crash-testing your Axon code on purpose, in a lab, with dummies — instead of finding out live in production.

RUN
TESTS
Press to begin ↓
CH. 00 — FOREWORD

Read This First

Axon code has no compiler standing between you and a live Haystack database. There's no red squiggly line, no type checker, nothing stopping a typo from quietly corrupting production data at 2am. axonTest exists so you find out your function is wrong on purpose, on your own schedule, in a disposable sandbox — not by accident, in front of your boss, in the real project.

This manual covers everything the framework can do, in the order you'll actually use it: run some tests, write your own, mock the scary parts, graduate to running against a real project, and finally point it at the thing itself. Every command and code sample in here was actually run against a real SkySpark 3.1.12 install. Nothing in this book is theoretical.

CH. 01 — QUICK START

Pushing The Big Red Button

No install. No pod build. No running server. Just a Fantom file and a directory of tests:

$ terminal
fan axonTest/fan/AxonTest.fan test/examples

This walks every *.axon file under that directory (recursively), runs each one as a test — or, for a suite, one per test registered inside it — and prints a summary that reads exactly like a crash-test report:

stdout
FAIL   suite_before_test_fails.testShouldNeverExecute (50ms)
       beforeTest failed: setup deliberately broken
PASS   suite_hooks_shared_state.testFixtureHasCorrectNum (70ms)
PASS   suite_hooks_shared_state.testFixtureIsFreshEachTime (33ms)
FAIL   suite_hooks_shared_state.testFixtureNumIsWrongOnPurpose (31ms)
       verifyEq failed: 42 != 999
PASS   suite_math.testAddition      (30ms)
FAIL   suite_math.testSubtractionIsWrongOnPurpose (29ms)
       verifyEq failed: 3 != 999
PASS   test_assertions              (28ms)
ERROR  test_expected_error          (28ms)
       Unknown symbol 'thisFunctionDoesNotExist'
FAIL   test_expected_failure        (26ms)
       verifyEq failed: 2 != 3
PASS   test_mockFunc                (26ms)
PASS   test_mockHttp                (82ms)
PASS   test_mockNow                 (30ms)
PASS   test_mockRec_and_readAll     (31ms)
PASS   test_verifyErr               (30ms)
---
14 tests, 9 passed, 4 failed, 1 errored

Three words, three lights: pass fail error. The exit code is 0 if every test passed and 1 otherwise — safe to wire straight into CI with no extra plumbing.

CH. 02 — CI OUTPUT FORMATS

Speaking Machine

The scrolling text report is for humans. For robots, ask for structured output:

fan axonTest/fan/AxonTest.fan test/examples -format=junit -out=report.xml
fan axonTest/fan/AxonTest.fan test/examples -format=json  -out=report.json
FlagWhat you get
-format=textThe default. The human-readable summary from Chapter 1.
-format=junit<testsuite>/<testcase> XML with <failure>/<error> — Jenkins, GitLab, and GitHub Actions JUnit-reporting actions all read this natively.
-format=json{"summary": {...}, "tests": [{"name","status","msg","durMs"}, ...]}
-out=<file>Write the report to a file instead of stdout — stdout stays clean, which matters when something downstream is piping it into jq or an XML parser.

The exit code rule from Chapter 1 doesn't change based on -format/-out.

FIELD NOTE

That "stdout stays clean" promise isn't a design intention, it's a proven fact — and it wasn't true on the first try. SkySpark's own shell machinery logs xeto/defc info-level noise straight to stdout, not stderr, the moment it boots. A dedicated regression test (CliTest.testOutFlagWritesReportToFileAndLeavesStdoutClean) caught this red-handed before it ever reached you. The fix: silence those two loggers to warn before the very first test context gets built. A real problem still shows — it just isn't drowned out by routine startup chatter.

CH. 03 — CI INTEGRATION

Wiring The Alarm

Ready-to-use workflow files ship for both GitHub Actions (.github/workflows/axon-test.yml) and GitLab CI (.gitlab-ci.yml). Both run the same one-liner and publish the JUnit report — GitLab reads it natively via artifacts.reports.junit; GitHub uses the mikepenz/action-junit-report action.

HAZARD

SkySpark is licensed software. A hosted/shared CI runner does not have fan or the Haxall pods installed by default, and never will by accident. Point runs-on:/tags: at a self-hosted runner (or a container image) that already carries this toolchain.

Also: this repo's own example suite deliberately contains a failing test and an erroring test, to prove the reporting actually works. Wiring CI to test/examples as-is will always report failure — swap in your own project's test directory first.

CH. 04 — WRITING YOUR FIRST TEST

Assemble Your Dummy

One .axon file is one test. Its name is its path relative to the test directory, minus .axon. Write a do … end block (or a single expression) and call the assertion funcs below — anything the script throws and doesn't catch fails or errors the test.

test/examples/test_assertions.axon
do
  verifyEq(2+2, 4);
  verifyNotEq(2+2, 5);
  verifyTrue(1 < 2);
  verifyFalse(1 > 2);
  verifyApprox(1.0001, 1.0, 0.01)
end

The Assertions

FuncBehavior
verifyEq(actual, expected)fails unless actual == expected
verifyNotEq(a, b)fails if a == b
verifyTrue(cond) / verifyFalse(cond)fails unless cond is exactly true/false
verifyApprox(actual, expected, tol := 0.01)fails unless |actual - expected| <= tol
verifyErr(msg, fn)calls the zero-arg fn; fails unless it throws. If msg is non-null, the thrown error's message must contain it. Pass null to just check something throws
fail(msg)always fails with msg
FIELD NOTE — PASS / FAIL / ERROR

A fail is an assertion that ran and disagreed with you. An error is something unexpected blowing up before an assertion even got a chance to. Keeping them separate means you can tell "the code is wrong" apart from "the test itself is broken" at a glance — look back at Chapter 1's report and you'll see both in the wild.

CH. 05 — MOCKING

Fake It Safely

No setup required for isolation: every test already gets its own in-memory database automatically, so nothing below ever leaks between tests or touches a real project.

FuncBehavior
mockRec(tags)commits a record into this test's isolated database and returns it
mockNow(dateTime)freezes now(), nowUtc(), and today() for the rest of the test. Call with null to unfreeze
mockFunc(name, fn)substitutes any top-level func — built-in or your own — with fn for the rest of the test
test/examples/test_mockRec_and_readAll.axon
do
  mockRec({dis:"Pt A", pt:"y", equipRef:"eq1"});
  mockRec({dis:"Pt B", pt:"y", equipRef:"eq1"});
  mockRec({dis:"Not a point"});
  verifyEq(readAll(pt).size, 2)
end
test/examples/test_mockFunc.axon
do
  mockFunc("double", (x) => x*2);
  verifyEq(double(21), 42)
end
CH. 06 — HTTP

Calling Outside The Lab

Fun fact, verified the hard way: this installed SkySpark has no outbound-HTTP axon func at all — every installed pod was scanned for an @Axon method with "http" in the name, and the only hit was an unrelated site-URL getter. httpGet/httpPost fill that gap with a real web::WebClient call; mockHttpUrl/mockHttp make them testable against a real local server instead of the actual internet.

test/examples/test_mockHttp.axon
do
  mockHttp("/status", "ok", 200);
  mockHttp("/broken", "nope", 500);

  r: httpGet(mockHttpUrl("/status"));
  verifyEq(r->status, 200);
  verifyEq(r->body, "ok");

  r2: httpGet(mockHttpUrl("/broken"));
  verifyEq(r2->status, 500);

  r3: httpGet(mockHttpUrl("/nope-not-registered"));
  verifyEq(r3->status, 404);  // unregistered routes 404, they don't error

  mockHttp("/echo", "", 200, "POST");
  r4: httpPost(mockHttpUrl("/echo"), "posted-body");
  verifyEq(r4->status, 200)
end

httpGet(url, headers := {}) and httpPost(url, body, headers := {}) return {status, body, headers}. They are real, unconditional network calls — nothing about them is test-specific — so mockHttpUrl/mockHttp are what make a test actually hit a server you control. That server is started lazily (only tests that call it pay for it) and stopped automatically after the test, pass, fail, or error.

Both funcs are also registered as real, production @Axon funcs on axonTestLib — a project that adds the lib gets outbound HTTP capability it didn't have before, testable from day one.

CH. 07 — SUITES

Grouping The Dummies

A single .axon file can also be a suite: instead of running assertions directly, its top-level expression evaluates to a Dict that registers several named tests, optionally sharing beforeTest/afterTest hooks — Kotest-style, if you've used that.

test/examples/suite_math.axon
{
  tests: {
    testAddition: () => do
      verifyEq(2+2, 4)
    end,
    testSubtractionIsWrongOnPurpose: () => do
      verifyEq(5 - 2, 999)
    end
  }
}

This registers two tests, reported as suite_math.testAddition and suite_math.testSubtractionIsWrongOnPurpose — each running in its own fresh, isolated context, exactly like separate files would.

There's no direct variable-sharing between hooks and a test body (every test gets a fully fresh context, so a var set in beforeTest wouldn't be visible anyway) — share state through the database instead, the same mockRec/readAll idiom used everywhere else:

test/examples/suite_hooks_shared_state.axon
{
  beforeTest: () => do
    mockRec({dis:"Widget", num:42, testFixture:"y"})
  end,
  afterTest: () => do
    verifyEq(readAll(testFixture).size, 1)
  end,
  tests: {
    testFixtureHasCorrectNum: () => do
      recs: readAll(testFixture);
      verifyEq(recs[0]->num, 42)
    end,
    testFixtureIsFreshEachTime: () => do
      // If isolation were broken and beforeTest ran cumulatively,
      // this would see more than 1 record.
      verifyEq(readAll(testFixture).size, 1)
    end,
    testFixtureNumIsWrongOnPurpose: () => do
      recs: readAll(testFixture);
      verifyEq(recs[0]->num, 999)
    end
  }
}

The Registration Contract

test/examples/suite_before_test_fails.axon
{
  beforeTest: () => do
    fail("setup deliberately broken")
  end,
  afterTest: () => do
    fail("afterTest should never run")
  end,
  tests: {
    testShouldNeverExecute: () => do
      fail("test body should never run")
    end
  }
}

In JUnit output, suite-produced results use classname="{suiteName}"/name="{testName}" so they group properly in CI dashboards; flat/legacy results keep the old fixed classname="axonTest".

FIELD NOTE

There is deliberately no beforeAll/afterAll. Every test in a suite gets its own fresh context, so there's nowhere for "run once across the whole suite" to live without either breaking per-test isolation or introducing a context that outlives any single test.

CH. 08 — AXONTESTLIB

Bolting It Into A Real Project

axonTestLib/ packages the same engine as a real installable hx::HxLib, so a running project can call it as an axon func instead of shelling out to the CLI.

$ terminal
cd axonTestLib
fan build.fan compile

BuildPod compiles straight into the environment's pod path — there's no separate install step. From inside a project (shell, Axon Debug window, or a task), add the lib once and call its funcs like any other:

libAdd("axonTestLib")
axonTestRun("test/examples")            // -> Grid: name, status, msg, durMs
axonTestRunJunit("test/examples")       // -> Str: JUnit XML
axonTestRunJson("test/examples")        // -> Str: JSON report
httpGet("https://example.com/status")   // -> Dict: {status, body, headers}
httpPost(url, "some body")              // -> Dict: {status, body, headers}
FIELD NOTE

That's libAdd, not addLib. Easy mix-up — addLib is a Fantom-only convenience method that lives inside the test harness code, never something you can type into a live axon shell. Ask me how directly this was learned.

HAZARD — THE IO/ SANDBOX

dir is always relative to this project's own io/ directory — never a raw host filesystem path. An absolute path or a .. segment is rejected outright, resolved through HxFileService.resolve (Haxall's own sandboxed, permission-checked file API), with an explicit upfront rejection of anything that looks like an escape attempt as a second layer on top. axonTestRun("test/examples") means <project>/io/test/examples — the same file space the Files app reads and writes.

This is deliberate, not an oversight: axonTestRun parses and executes whatever *.axon files it finds, so a caller must never be able to point it at another project's files, or anywhere else on the host.

CH. 09 — INTEGRATION MODE

Taking Off The Training Wheels

Everything so far runs in a fully isolated sandbox — a fresh, empty database per test, blind to your project's real records or funcs. Pass true as a second argument to opt into integration mode, which instead runs test files against your project's real, already-live data and functions:

libAdd("axonTestLib")
axonTestRun("test/integration", true)
axonTestRunJunit("test/integration", true)
axonTestRunJson("test/integration", true)

In this mode, readAll/read see your project's real records, commit performs real writes, and any real project func — a func: record or one registered by another installed lib — is callable by name, exactly like typing it into the axon shell yourself. All seven assertion funcs from Chapter 4 still work — they're registered as real @Axon funcs specifically so this mode has something to assert with.

HAZARD — THIS SAFETY NET IS YOU

The safety model here is operational, not technical. Nothing stops or rolls back a real write in this mode. Point it at a disposable/test SkySpark project with restricted user rights before writing anything that mutates data. Most integration-mode tests should be read-only checks — treat writes as the exception, and run those against throwaway state on purpose.

What's Different From The Sandbox

FIELD NOTE — THE FUNC RECORD YOU JUST COMMITTED ISN'T CALLABLE YET

A project function is a real record with two tags: def: ^func:{name} and src holding the axon source, straight from SkySpark's own Axon.html#funcRecs documentation. If a test commits one of these and immediately tries to call it, expect an "Unknown symbol" the first time — a running daemon reconciles new func records into the top-level namespace almost immediately in the background, but "almost immediately" still isn't instant. This exact gap is what an ephemeral test-harness-booted runtime's forceSteadyState() exists to paper over during development; a real production daemon settles this on its own.

CH. 10 — TESTING AXONTEST ITSELF

The Dummy Tests The Dummy

axonTestLib/test/ is a real Fantom test suite (run via fant, not this framework's own .axon runner — it tests the Fantom code that implements axonTest) covering both deliverables:

$ terminal
fant axonTestLib
FileWhat it covers
TestRunnerTest.fanPure unit tests of the runner/reporter — every suite edge case, JUnit/JSON shape. No daemon, no subprocess. Fast.
AxonTestLibTest.fanReal-daemon round-trip: boots an actual hxd runtime, adds the lib, calls its @Axon funcs via genuine axon evaluation. Includes integration mode's own proof: a real committed record and a real func: record both visible/callable from a live run.
CliTest.fanShells out to the real CLI script and checks exit codes/stdout for every flag and error path — the one layer nothing else here exercises.

This is the suite that caught the stdout-noise bug mentioned back in Chapter 2 — a real regression, found by the framework, in the framework, about the framework. Appropriately recursive for a testing tool.

CH. 11 — KNOWN QUIRKS

Read The Warning Labels

CH. 12 — CHEAT SHEET

For The Dummy In A Hurry

Skipped straight to the back of the book? Here's the whole API on one page.

FuncModeDoes
verifyEq / verifyNotEqbothequality checks
verifyTrue / verifyFalsebothboolean checks
verifyApproxbothnumeric within tolerance
verifyErrbothexpects a closure to throw
failbothalways fails
mockRecisolated onlycommit a fake record
mockNowisolated onlyfreeze the clock
mockFuncisolated onlyswap out any func
mockHttpUrl / mockHttpisolated onlyfake HTTP server + route
httpGet / httpPostbothreal outbound HTTP
axonTestRun(dir, integration:=false)axonTestLibrun tests, get a Grid
axonTestRunJunit(dir, integration:=false)axonTestLibrun tests, get JUnit XML
axonTestRunJson(dir, integration:=false)axonTestLibrun tests, get JSON
APPENDIX — DIRECTORY LAYOUT

Where Everything Lives

axonTest/fan/AxonTest.fan       standalone CLI (single runnable file, zero install)
axonTestLib/                    real HxLib pod exposing axonTestRun()/etc as axon funcs
  build.fan                     BuildPod - `fan build.fan compile` builds + installs
  lib/lib.trio                  lib def (name, depends, typeName)
  fan/AxonTestCore.fan          same engine as AxonTest.fan, minus the CLI Main
  fan/AxonTestLibLib.fan        HxLib lifecycle class
  fan/AxonTestLibFuncs.fan      @Axon funcs: axonTestRun/axonTestRunJunit/axonTestRunJson
  test/                         `fant axonTestLib` - tests axonTest's own Fantom code
test/examples/                  example tests demonstrating every feature
test/smoke/                     minimal smoke test used during development

The CLI and axonTestLib deliberately duplicate the same engine classes — they serve different use cases (zero-install script vs. an installed project lib) with different dependency needs.