- Forthcoming
- Testing Framework
Netsukefile Testing Framework
Netsukefiles contain real logic — foreach, when, macros, environment probes, globbing,
command_available — and today the only way to verify it is to run a build and eyeball the output.
netsuke test runs declarative tests through the same compiler pipeline as netsuke build and asserts on
the rendered manifest, the build graph, and the generated Ninja text, deterministically and without executing a single recipe.
Status on 19 October 2026
- Not in v0.1.0-beta4. The released binary has no
testcommand, notestsmanifest key, and no test dialect. Only the first seam has landed on main:now()reads an injectable clock. Manifest authors see no change. - Designed. RFC 0007 (proposed, 17 August 2026) sets the scope; the UX and semantic design and the technical design are drafts. Roadmap phase 7 tracks twenty-one tasks. The first, 7.1.1, the clock provider seam, landed on 24 September 2026 in #696 and makes
now()read an injectable, UTC-normalized clock; the other twenty remain open. Issue #591 tracks the feature. - Gated on evidence. Before the dialect and mock engine are built, the seams are dogfooded through a differential fidelity suite over the repository's example manifests; if the dialect's demand assumptions fail, the design's explicit exit is overrides only, with no dialect.
- Preview targeted at v0.3.0. Issue #591 names v0.3.0 for the first usable release; the repository’s own documents name no release. The success criterion is concrete: an author can write the worked example below and run it to a green result on a machine with no compiler, no network, and a fixed clock. Key names, matchers, and output shapes may change before then.
- Extended by a follow-on. Property testing (RFC 0012, roadmap phase 10) builds on this framework and its dialect.
Why manifest tests
Four things cannot be checked today: negative properties (that skip.c is not a target), environment-dependent
behaviour (what the manifest does when CC is unset), refactoring safety, and a machine-checkable contract that an
agent or CI job can run before and after editing a manifest. The design rests on three commitments.
Same compiler, declared substitutions
Tests use the real loader, expansion, IR lowering, and Ninja generator. Mocks are substitutions at named seams — template functions, environment, clock, macros — never a re-implementation that can drift from the product.
Plan mode is the unit-testing story
Like OpenTofu's plan-mode tests, every pipeline action stops at generated Ninja text and never executes build commands. An unmocked impure call is an error, not a silent passthrough.
Failure output is a feature
A failing assertion is rendered as the expression with its actual values substituted, in the style of Open Policy Agent. An unexpected mock call suggests the configuration stanza that would have accepted it.
Test tree and files
Tests live in a netsuke-tests directory beside the Netsukefile. An optional top-level tests block in the
manifest reconfigures discovery; it is tool configuration, not build data, so its values are invisible to templates and
tests.root accepts no Jinja. Files whose name starts with _ are support files, loaded only when imported.
A run that discovers no files, or whose filters select zero cases, fails rather than reporting a silent green.
netsuke_version: "1.2.0"
tests:
root: netsuke-tests
include:
- "**/*.yml"
- "**/*.yaml"
exclude:
- "**/_*.yml"
- "**/_*.yaml"
netsuke_test_version: "1.0" # required in every file
imports: # optional support files,
- _fixtures.yml # confined to the test tree
vars: {} # suite-local test variables
macros: [] # same shape as Netsukefile macros
fixtures: {} # fixture definitions
test_compile_target: # one or more cases
steps: []
netsuke_test_version is a MAJOR.MINOR string. The dialect denies unknown keys everywhere, so every addition is a minor-version event and an older runner rejects a newer file rather than misreading it; an unknown key is reported with the nearest known key. Case names match test_[A-Za-z0-9_]+ and are reported as <file>::<name>.
Given, when, then
A case is an ordered list of steps. Each step carries at least one of given (fixtures, environment,
doubles, clock), when (a pipeline action), and then (assertions). Context from given persists
across the later steps of the same case; nothing persists between cases. The smallest useful test needs none of the machinery:
netsuke_test_version: "1.0"
test_hello_target_is_generated:
steps:
- when: generate_ninja
then:
- result.ok
- result.graph.has_target("build/hello.txt")
when: pipeline actions
| Action | Runs | Result carries |
|---|---|---|
| load_manifest | ingest, parse, expand, deserialize, render | result.manifest |
| build_graph | load_manifest + IR lowering | result.graph |
| generate_ninja | build_graph + Ninja generation | result.ninja |
Scalar, list, and object forms are accepted; the object form can name an explicit manifest path, typically a fixture export.
then: assertion forms
Scalar entries are MiniJinja boolean expressions over the result and the mock journal; object entries are structured assertions with richer diffs. Helpers available in expressions: contains, starts_with, ends_with, matches, file_exists, file_contains.
then:
- result.ok
- result.graph.targets | length == 3
- equals:
actual: result.graph.edge_count
expected: 4
- contains:
value: result.ninja
needle: "default app"
- matches:
value: result.error.message
regex: "missing.*rule"
A false assertion is a FAIL. An assertion whose evaluation raises — an undefined name, a type mismatch — is an ERROR, so a typo such as result.grpah cannot pass itself off as an ordinary red result. Negative tests name the diagnostic they expect with expect_failure, so a different bug cannot satisfy them: message_contains works from the start, while matching on code waits until the diagnostic code namespace is declared stable.
Doubles and fixtures
given.let binds a double to a template function name. The three kinds follow the stub, mock, and spy taxonomy: a
stub returns a canned value and is journalled but never verified; a mock is verified, so every
declared expectation must be met and an unmatched call fails the action immediately; a spy journals every call and
passes through to the effective implementation under test configuration. The terse form is deliberately the loose one, so
don't-care collaborators cost one line and full expectation machinery is opt-in.
given:
env:
set:
CC: clang
let:
glob: mock(args=["src/*.c"], returns=["src/main.c"])
cc: stub(returns="clang")
now: spy()
compile_cmd: substitute("stand_in_compile")
What can be mocked
| Seam | Mechanism | Example |
|---|---|---|
| Template functions | mock/stub/spy | glob, which, fetch, command_available |
| Environment variables | given.env | env("CC") |
| Clock | given.clock | now() |
| Manifest macros | substitute(...) | compile_cmd(...) |
| Filesystem observations | fixtures and given.fs | file tests, sandboxed glob |
Filters and Jinja tests are not mockable in the first version; filesystem fixtures cover most file-test cases with real sandboxed files, which is simpler and higher fidelity. Spying fetch is a suite error because the deny-all network policy makes its real implementation unreachable.
Fixtures
Fixtures are lifecycle objects: ordered setup actions, exports, and ordered teardown, with an action
vocabulary of tmpdir, mkdir, write, copy, remove, and env. Every path resolves inside a
per-case sandbox; a fixture cannot touch the project root or the host filesystem. Teardown runs in reverse order for every fixture
whose setup completed, regardless of later failures, and a failing teardown never masks the case result. All fixtures are case-scoped
in the first version; an arbitrary-command run action is deferred with execution generally.
fixtures:
tiny_c_project:
description: Minimal C project with one source and a Netsukefile.
setup:
- tmpdir: project
- mkdir: "{{ project }}/src"
- write:
path: "{{ project }}/src/main.c"
text: |
int main(void) { return 0; }
- write:
path: "{{ project }}/Netsukefile"
text: |
netsuke_version: "1.2.0"
targets:
- name: build/main.o
command: "cc -c src/main.c -o build/main.o"
sources: src/main.c
exports:
root: "{{ project }}"
manifest: "{{ project }}/Netsukefile"
The command
netsuke test [FILTER...]
Arguments:
FILTER Case selectors: file paths, file::case names, or
substring patterns
Options:
--tests-dir <DIR> Override tests.root
--list List discovered cases without running them
--tag <TAG> Run only cases with this tag (repeatable)
--skip-tag <TAG> Exclude cases with this tag (repeatable)
--fail-fast Stop after the first failing case
--timeout <SECS> Per-case wall-clock budget (default 60)
--keep Preserve sandboxes of failing cases
--allow-empty Succeed when zero cases are selected
Exit codes
| 0 | all selected cases passed |
| 1 | at least one case failed or errored |
| 2 | invalid suite, invalid selector, or zero cases without --allow-empty |
| 3 | internal runner error |
| 130 | interrupted |
Conventions it inherits
-
--jsonand--jobsare the existing global flags. A completed run writes exactly one JSON document to stdout; a run that produces no report writes one diagnostic document to stderr. - The colour, emoji, progress, and accessibility policies and the localized message catalogue apply as they do everywhere else.
- Every selected case is reported exactly once as
passed,failed,errored, orskipped, so counts always add up to the selection, even under--fail-fastor an interrupt. - A case that exceeds its timeout is terminated, reported as errored with the journal it had produced, and keeps its sandbox for inspection.
Worked example
The case below verifies foreach expansion, when filtering, environment-driven command construction, and
macro wiring — without a compiler installed, without touching the real filesystem, and identically on every machine.
It is the design's own success criterion.
netsuke_version: "1.2.0"
tests:
root: netsuke-tests
macros:
- signature: "compile_cmd(src, obj)"
body: |
{{ env('CC') }} -c {{ src }} -o {{ obj }}
targets:
- foreach: glob('src/*.c')
when: item | basename != 'skip.c'
name: "build/{{ item | basename | with_suffix('.o') }}"
command: "{{ compile_cmd(item, 'build/' ~ (item | basename | with_suffix('.o'))) }}"
sources: "{{ item }}"
defaults:
- build/main.o
netsuke_test_version: "1.0"
macros:
- signature: "stand_in_compile(src, obj)"
body: |
STUB {{ src }} -> {{ obj }}
test_skips_filtered_sources:
description: foreach expands sources; when filters skip.c;
the compile macro can be substituted.
tags: [manifest, foreach]
steps:
- given:
env:
set:
CC: clang
let:
glob: mock(args=["src/*.c"],
returns=["src/main.c", "src/skip.c"])
compile_cmd: substitute("stand_in_compile")
when: generate_ninja
then:
- result.ok
- result.graph.has_target("build/main.o")
- not result.graph.has_target("build/skip.o")
- contains(result.ninja, "STUB src/main.c -> build/main.o")
- mocks.glob.call_count == 1
- substitutes.compile_cmd.call_count == 1
$ netsuke test
PASS compile.yml::test_generates_object_targets
FAIL compile.yml::test_substituted_macro_receives_source
then[1]: substitutes.compile_cmd.calls[0].args[0] == "src/main.c"
substitutes.compile_cmd.calls[0].args[0] = "src/lib.c"
2 cases: 1 passed, 1 failed
From the shallow end
The worked example above is the design’s finish line, not its starting point. The quick start in the UX design needs
nothing beyond a Netsukefile and one test file in the default netsuke-tests directory: no tests block,
no fixtures, no doubles, and no given. The test is the hello.yml case shown under
given, when, then.
netsuke_version: "1.2.0"
targets:
- name: build/hello.txt
command: echo hello > build/hello.txt
$ netsuke test
PASS hello.yml::test_hello_target_is_generated
1 case: 1 passed
That case needs no compiler, no network, and no real files, because generate_ninja runs against the in-memory
pipeline. From there the path deeper is a ladder, and each rung is opt-in:
- 1Assertions only. Boolean expressions over
result, withthenafter a pipeline action. - 2A loose stub.
stub(returns=…)ingiven.letis the one-line default for a collaborator the test does not care about. - 3A spy or a verified mock.
spy()records calls to the effective implementation;mock(…)adds expectations that must be met. - 4The structured
mocksblock. The full-fidelity form every shorthand desugars to, with matchers and call configurations. - 5Fixtures and macro substitution. Sandboxed files and stand-in macros, as in the worked example.
The design calls this strictness ladder deliberate: over-mocking is the documented failure mode of expressive mock frameworks,
so the terse form stays the loosest. Test files declare macros exactly as a Netsukefile does, so authors carry one mental model
between manifests and tests, and a mistyped key such as fixture: is rejected with the nearest known key.
Beyond the first version. The UX design lists its deferred features in likely delivery order: build execution behind explicit allow flags, filter and Jinja-test doubles, file- and session-scoped fixtures, data-driven case tables, snapshot assertions, and JUnit XML with an idempotence check. Separately, property testing (RFC 0012, roadmap phase 10) builds on this framework with quantified assertions, bounded generation, and metamorphic mutations, without reopening its closed dialect.
Accessibility and localization
netsuke test is designed to follow the established display policy flags — --color,
--emoji, --progress, and --accessibility — rather than invent its own, so the
accessible output modes apply to test reports as they do to
builds. Every human-facing string is to be localized through Fluent.
Keys early, not at the end
The build-time completeness audit requires every key in all 34 catalogues beyond en-US. The technical design defines keys early in the phasing so translation lands in batches rather than as a release-blocking cliff. Roadmap 7.6.2 adds the Fluent keys for report lines, diagnostics, and warnings.
Machine text stays invariant
Two artefacts are deliberately not translated: the suggested YAML stanza for an unmatched call, and the expression-with-values rendering in a failure report. Both are meant to be pasted or matched, not read as prose. The --json report keeps the one-document stream contract.
A documented accessible contract
Roadmap 7.6.3 documents how case results, failure diagnostics, and the run summary render under --accessibility on, and records the findings against task 3.8.3, the still-open verification with assistive technology. Until then the design names the contract but not its exact wording.
Under the bonnet
One compiler, two new seams
Implementation reuses the existing pipeline behind an options-carrying loader entry point that registers test overlays after the standard library and manifest macros and before foreach expansion. Two seams are added. The clock provider is already on main (7.1.1, #696); the macro-substitution overlay is still to come (7.1.3). Sandbox-rooted glob() and file tests are adapters for the test registration rather than new seams (7.4.1). Network mocking needs no transport seam, because the deny-all policy plus function-level doubles make the real network code unreachable under test.
A killable child per case
Each case runs in its own child process so --timeout is a hard bound rather than a cooperative courtesy: MiniJinja evaluation cannot be pre-empted in-process. Discovery, scheduling, and report rendering stay in the parent. Results travel over length-prefixed JSON frames versioned like the existing envelope, with incremental journal checkpoints, so a case killed on the deadline still reports the calls it had made.
Hermetic and deterministic
Per-case sandboxes built on capability-scoped filesystem handles; no ambient mutation; no execution; a fixed clock; and localized, stream-pure output. The build path ignores the tests block entirely, so a manifest with a test suite behaves identically to one without.
Out of scope
Executing builds or fixture shell commands (designed, but deferred behind explicit allow flags); replacing Netsuke's own Rust test suites; a general-purpose scripting language for tests; file- and session-scoped fixtures; JUnit XML output. The tests key is admitted from the manifest version of the release that ships it, and older binaries reject it with their ordinary unknown-field diagnostic.
How the design earns trust
Ten named invariants
The technical design states invariants I1 to I10, each with a verification method. Teardown-exactly-once is property-tested over generated fixture graphs of up to sixteen fixtures with failures injected at every lifecycle point; semantic fidelity is checked by differential tests against the build path over the example manifests; the hard timeout is tested with a deliberately non-cooperative template expression.
No silent greens
An empty run fails unless --allow-empty says otherwise. FAIL and ERROR stay distinct. An unmocked impure call is an error. And an older runner rejects a newer test file rather than misreading it.
Evidence before the dialect
Task 7.1.4 dogfoods the seams through a differential fidelity suite before any dialect work begins, with overrides-only as the stated exit if demand fails to appear.
Already practised on 7.1.1
The clock seam shipped with six named obligations, each paired with a mutation it must reject, plus a proptest over the valid UTC offset range. The retrospective credits the mutation exercise with catching a near-miss in offset handling.