The test runner
Cruft's build of Node's built-in test runner: writing tests with node:test, discovering and running them with cruft --test, filtering by name, reporters, mocking, the TestContext, and exit codes. Names the ways it diverges from Node and what is still deferred.
Cruft ships Node's built-in test runner: import test from "node:test", cruft --test discovery, reporters, mocking, and the TestContext surface.
Under active development. This surface is younger and moving faster than most pages in these docs. The common workflow (write tests, run them, filter them, read failures, mock functions, get correct exit codes) works today, but expect the long tail (watch-mode taxonomy, mock.module, per-file process isolation) to change shape.
The architecture
The runner follows Cruft's standard two-tier pattern, applied cleanly:
cruft:test: the primitive kernel: a tree of suites/tests/hooks, a scheduler, and a typed result-event stream. It has no reporter format, no CLI, no Node quirks, and lowers only onto already-primitive surfaces (a failed assertion is just a caught throw; async is promises; timeouts are timers).node:test: the adapter over Node's exact surface on that kernel:test/describe/it, hooks,skip/todo/only, the TestContext, reporters, exit-code semantics. Every place the adapter diverges from Node is a named compatibility exception (listed below), never a special-case baked into the kernel.
Writing tests
import test, { describe, it, beforeEach } from "node:test";
import assert from "node:assert";
test("addition", () => { assert.strictEqual(2 + 2, 4); });
test("async works", async () => { await something(); });
test("skipped", { skip: true }, () => {});
test("todo item", { todo: true }, () => {});
describe("suite", () => {
beforeEach(() => { /* per-test setup */ });
it("hooks ran", () => { /* … */ });
it("subtests", async (t) => {
await t.test("nested", () => assert.ok(true));
t.diagnostic("a note");
});
});
test("mocking", (t) => {
const fn = t.mock.fn((x) => x * 2);
fn(21);
assert.strictEqual(fn.mock.calls[0].result, 42);
});
test("plan", (t) => {
t.plan(2);
t.assert.ok(true);
t.assert.strictEqual(1, 1);
});
Running that file directly produces Node's spec-reporter shape:
✔ addition (0.136475ms)
✔ async works (10.312256ms)
﹣ skipped (0.008789ms) # SKIP
✔ todo item (0.105713ms) # TODO
▶ suite
✔ hooks ran (0.132324ms)
▶ subtests
✔ nested (0.164307ms)
ℹ a note
…
ℹ tests 9
ℹ pass 7 … ℹ skipped 1 ℹ todo 1
Semantics worth knowing:
- Exit codes are Node's: any failure → exit
1; all pass →0. - **
t.plan(n)counts context assertions** (t.assert.*) and subtests, module-levelassert.okdoes not count, matching Node. A short plan fails withplan expected 2 assertions but 0 ran. t.mock.fnrecords calls with arguments and results; the broader mock surface (spies, timers) is present;mock.moduleis deferred.- TestContext projects the common properties (
name,fullNamewithouter > innernesting,signal,waitFor(),t.assertincludingpartialDeepStrictEqual).
The CLI
cruft --test # discover *.test.* files and run them
cruft --test --test-name-pattern="…" # filter by name
cruft --test --test-reporter=tap # TAP version 13 output
The TAP output is valid TAP 13 (# Subtest:, ok N - name, # SKIP/# TODO, the 1..N plan, summary counts).
Named compatibility exceptions
Where the adapter diverges from Node, here is the full list of what you will actually see:
| Exception | Effect |
|---|---|
DEV-TAP-TIMING | Timing digits can never match Node byte-for-byte; structure matches, wall-clock doesn't (inherent) |
DEV-STACK-INTERNAL | Failure stacks show cruft:internal/test_runner.js frames where Node shows its own internals (inherent) |
DEV-ASSERT-MESSAGE | Failure detail shows Cruft's terser assert messages where Node shows its rich ERR_ASSERTION diff; this is an assert-formatting limitation, and the runner itself is unaffected |
DEV-TEST-CLI-ISOLATION | cruft --test runs all files in one process (Node's --test-isolation=none equivalent): counts and exit codes match, but a hard-crashing file takes down the whole run where Node would isolate it |
DEV-ONLY-STATIC | .only / name-pattern filter the statically-declared tree |
DEV-LOCATION-FORMAT, DEV-TAP-DIAGNOSTIC | Cosmetic: location formatting; TAP YAML blocks emitted for failures only |
Status
The common workflow is solid: write tests, run them, filter them, read failures, mock functions, and get correct exit codes. Deferred for now: mock.module, per-file process isolation, and the long-tail TestContext/watch-event taxonomy. Confirm anything not listed above against your own build before relying on it.