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 3 September 2026
- Not in v0.1.0-beta3. Nothing described here is implemented: there is no
testcommand, notestsmanifest key, and no test dialect in the released binary. - 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 the work, with every task still open.
- 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.4.0. 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.
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; a case that could not run to a verdict is an ERROR. The two are distinct end to end, and expected failures can be asserted on by name.
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
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. Only two seams are added: a clock provider and a macro-substitution overlay. 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.