Forthcoming capability
Preview targeted at v0.3.0 Proposed RFC

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 test command, no tests manifest 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.

Netsukefile: discovery block and its defaults
netsuke_version: "1.2.0"

tests:
  root: netsuke-tests
  include:
    - "**/*.yml"
    - "**/*.yaml"
  exclude:
    - "**/_*.yml"
    - "**/_*.yaml"
A test file's fixed keys plus test_* cases
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-tests/hello.yml
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

ActionRunsResult carries
load_manifestingest, parse, expand, deserialize, renderresult.manifest
build_graphload_manifest + IR loweringresult.graph
generate_ninjabuild_graph + Ninja generationresult.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.

Shorthand doubles in given.let
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

SeamMechanismExample
Template functionsmock/stub/spyglob, which, fetch, command_available
Environment variablesgiven.envenv("CC")
Clockgiven.clocknow()
Manifest macrossubstitute(...)compile_cmd(...)
Filesystem observationsfixtures and given.fsfile 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.

A fixture from the UX design
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, as designed
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

0all selected cases passed
1at least one case failed or errored
2invalid suite, invalid selector, or zero cases without --allow-empty
3internal runner error
130interrupted

Conventions it inherits

  • --json and --jobs are 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, or skipped, so counts always add up to the selection, even under --fail-fast or 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.

Netsukefile (subject)
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-tests/compile.yml
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
Human report, with one failing assertion rendered with its actual value
$ 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.

Netsukefile (quick start, UX design §14.1)
netsuke_version: "1.2.0"

targets:
  - name: build/hello.txt
    command: echo hello > build/hello.txt
A passing run, as designed
$ 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:

  1. 1Assertions only. Boolean expressions over result, with then after a pipeline action.
  2. 2A loose stub. stub(returns=…) in given.let is the one-line default for a collaborator the test does not care about.
  3. 3A spy or a verified mock. spy() records calls to the effective implementation; mock(…) adds expectations that must be met.
  4. 4The structured mocks block. The full-fidelity form every shorthand desugars to, with matchers and call configurations.
  5. 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.