Skip to content

Testing policy

Status: Implemented for the rules marked lint; the rest is review (decision). Tests prefer real boundaries over mocks of owned code, because a mock can keep a test green while the real path is broken. The maia-test ESLint rules cover tests and testing/ support in every workspace src/.

Boundary

Test observable behavior through the owning public interface, at the lowest boundary that can fail for the real reason: pure logic as a unit, storage on real SQLite (:memory: or a temporary file with real migrations), transport over real authenticated loopback, a process contract against the built executable, and a user journey in e2e/ on the exported artifact.

Doubles

Prefer, in order: the real thing, a faithful fake, a stub, a mock. Doubles are allowed only for third-party APIs behind an owned adapter, OS and native facilities (procfs, signals, ChildProcess), and fault injection through a narrow typed port the production code already declares.

  • lint owned-module-mock: no vi.mock, vi.doMock or mock.module of a relative, @/ or @maiaos/* module.
  • lint owned-double-cast: no as unknown as into an owned type (LocalNodeClient, CliDependencies, NodeDatabaseResource and the like): one from a relative, @/ or @maiaos/* module, or a local type built from one. Build the real client with createLocalNodeClient over a scripted transport, and pass invalid input to the decoder as unknown. Casting a vendor value passes; vi.fn() and Effect values count. OS facility aliases are listed in the policy object. The rule does not see as never; reviewers do.

A fake shared by several test files lives in the owning scope's testing/ and passes the same contract cases as the live implementation.

Time

Do not sleep to sequence a test: wait on the event (Deferred, Latch, a stream line, child IPC). Effect code runs under TestClock (it.effect/it.layer in Vitest, TestClock.layer() with node:test) and advances it with TestClock.adjust. A wall-clock deadline is only a hang guard, at least ten times the typical duration, never the assertion. A speed budget counts the CPU time of the work (process.cpuUsage, median of warm runs), which machine load does not stretch like wall time. A test that does real I/O (real SQLite files, the built Node and CLI) sets its own timeout as the hang guard, never a longer global default, and splits a loop of independent cases into one test per case so each gets a fresh timeout. Vitest's 5 s default guards only a test under 0.5 s idle, and a gate at load 25 to 40 stretches a 1 s test to 5 s or more: size the guard from the idle duration, with vi.setConfig({ testTimeout }) in a file whose tests all do such work.

  • lint test-sleep: no setTimeout/setInterval call (global or from node:timers) unless its handle or delay is named for a hang guard (deadline = setTimeout(…), HANG_GUARD_MS). Inside a new Promise executor, a timer that resolves is a sleep and one that rejects is a deadline; elsewhere the function that owns the timer must be named for a guard (withDeadline). No node:timers/promises, and no vi.useFakeTimers in a file that imports Effect.

Hermetic runs

The node:test preload (scripts/lib/test-environment.mjs) points MAIA_HOME at a throwaway directory and drops every GIT_* variable. Tests own their temporary directories and never touch ~/.maia, the developer's repository or a non-loopback network. pnpm test puts a relative ./node_modules/.bin first on PATH; code that fails closed on a relative PATH entry (the vendor CLI detector) needs a test that passes it absolute entries only.

Contracts and assertions

Every callable operation needs a protocol schema test, an SDK request test over a transport, a Node handler test on real SQLite and, where the CLI or SDK exposes it, a built-boundary test; scripts/quality/registry-parity.mjs checks that the registry, SDK and CLI agree. Assert outcomes (values, rows, receipts, events, bytes); keep call assertions for "must not happen" guards.

Fixtures, snapshots and flakes

Third-party fixtures are recorded from the real API, redacted, committed with the vendor version and date, and typed or decoded without casts. Avoid snapshots; CI never writes them. No global retries: fix a flaky test within a working day, or record it with its cause, owner and exit date. A fixture that needs a kernel condition it cannot force (a loopback socket the peer blocks) may detect that the condition was lost and rerun that one scenario on fresh fixtures; product assertions never retry.

Coverage

pnpm coverage measures line and branch coverage per package: Node's built-in coverage for the node:test suites and @vitest/coverage-v8 for protocol and Node. The metrics coverage suite records it; no floor gates it yet. A bug fix includes a test that fails without the fix.

Baseline

The testQuality section of scripts/quality/lint-policy-baseline.json holds the counts that predate these rules, per (rule, path), and only shrinks, like the Effect baseline. After a migration, run node scripts/eslint/test-quality/audit/audit.mjs --prune. The rules read syntax: a stub typed some other way or a sleep behind a helper passes them; reviewers still check those.

Imported from Maia OS docs at a2175bb