ADR-247: An Output Test Runs in a Directory of Its Own, Its Expectations Are Blessed, and a Test Gets a Scratch Root

Status: Accepted Date: September 28, 2026 Extends: ADR-269 D13 (an output test is three files and no code) Answers: the owner’s question whether a program whose result is a file it writes needs a command of its own, and how other languages and test tools do it Related: ADR-108 (a path goes under a Root), Part III 14.1

1. Context

ADR-269 D13 makes an output test three files: NAME.stdout, and beside it NAME.stdin and NAME.args. That compares standard output only, and some programs - the examples tally, report and inventory/ - produce a file.

The owner asked, before anything was built, whether this belongs in the language at all, and what other languages and test tools do. The comparison:

layer what it holds in other languages
the language test blocks, assert Zig test "…" {}, D unittest, Go func TestX, Rust #[test]
the standard library a fresh directory per test, cleaned up Go t.TempDir(), Zig std.testing.tmpDir(), pytest tmp_path, ExUnit @tag :tmp_dir
the test tool run a program, compare its output and the files it wrote with expectations Go’s testscript (how the go command tests itself), Rust’s trycmd, rustc’s UI tests, LLVM’s lit

No language has syntax for it. The tools that do it agree on four things:

  1. Every test runs in a directory of its own, created for it and removed after it. Nothing a test writes lands in the source tree.
  2. What goes in and what must come out are trees, not loose files: trycmd copies a test’s .in/ directory in as the working directory and compares its .out/ directory afterwards; testscript holds both in one archive.
  3. An expectation is blessed, not typed. rustc --bless, cargo insta review, jest -u and Go’s -update convention write what the program did as the new expectation, and the person reviews the change in version control. An expectation written by hand is a chore for every intended change, and the chore is where mistakes come from.
  4. A failure is shown as a difference, not as two texts to compare by eye.

And they share one piece of advice about programs: compute the content in a function and write it in one call, so that most tests check the function and a few check the file.

What this language already has is the other half of point 1. A path goes under an fs::Root (ADR-108), so a function that writes takes the directory it writes under. A test can hand it a directory of the test’s own - if std gives it one.

Against the four, an output test that runs in the package’s directory writes into the source tree, keeps its input files loose in tests/, has its expectations written by hand, and prints both texts in full on a failure.

2. Decisions

D1 — An output test runs in a directory of its own.

nikaia test creates a fresh, empty directory for each output test at each setting, runs the program with it as the working directory, and removes it afterwards. Nothing the program writes reaches the package. The same holds for a test block: it runs in the package’s directory, as Go’s tests do, so it can read what the package holds, and D5 is how it writes.

D2 — What goes in is NAME.in/, and what must come out is NAME.out/.

file is
NAME.stdout what the program must print, byte for byte
NAME.out/ files the program must have written, byte for byte, each under the name it has in the working directory
NAME.in/ optional: copied into the working directory before the run
NAME.stdin optional: standard input
NAME.args optional: the arguments, one per line

A test is named by a NAME.stdout or a NAME.out/, and has at least one of the two. Standard output is compared where NAME.stdout exists. A file under NAME.out/ must exist with the same bytes; a file the program writes that NAME.out/ does not name is not compared - a test says what it is about. The program must end successfully, as ADR-269 D13 says.

An input file is NAME.in/FILE, and the arguments name it as FILE, never as tests/FILE.

D3 — nikaia test --bless writes the expectations from what the program did.

For each output test, --bless writes what the program printed into NAME.stdout (where the test has one) and each file it wrote into the NAME.out/ entry of the same name. It blesses only a run that ended successfully, and at --both-settings only one whose two runs agree - an outcome that differs between the settings is a fault to report, never an expectation. A test block has no expectation to bless: its claims are written in its body. The review is the difference in version control.

A new output test is started by creating the empty NAME.stdout, or the NAME.out/ entries it should compare, and blessing.

D4 — A failure shows the difference.

Where standard output or a written file differs from its expectation, the report is a line difference - - the expected line, + the one the program produced, and two lines of context - rather than both texts. A file the program did not write is reported as not written.

D5 — fs::scratch() is a fresh directory as a Root.

test "the page is written where it is asked for" {
    let root = fs::scratch()
    publish(entries, "page.html", root)
    let page = fs::read_to_string("page.html", root)
    assert(page.contains("<table>"))
}

fs::scratch() -> fs::Root throws creates an empty directory and hands it back as fs::Root::Dir, so what a test writes is checked to stay inside it (ADR-108 D3). Under nikaia test it is created inside the test’s own directory, which is removed when the test’s process ends. Outside a test it is a new directory under the system’s temporary directory, and removing it is the operating system’s business, as it is for any temporary file.

3. Not covered

Placeholders for what differs between runs - a timestamp, a path, a process id. trycmd writes [..], insta redacts; a program that prints the time cannot have an output test. Not decided here.

A file system in memory. Go’s fstest.MapFS tests a reader without a disk. A Root is a directory; D5 is the disk-backed half and is what the examples need.

4. Considered and rejected

Syntax in the language. No language has it, and nothing about a file comparison needs the compiler: it is the test tool’s, as ADR-269 D13 puts it.

NAME.files/ beside NAME.stdout. It says what must come out and not what goes in, and it kept inputs as loose files in tests/, reachable only because the test ran in the package’s directory - which D1 removes.

Expectations written by hand only. Every intended change of output becomes editing files to match a run; that is what D3 replaces, and the reason every tool compared has a bless mode.

5. Consequences

This site is open source. Improve this page.