4.2 Declared suites and pinned counts¶
Most projects discover their test suites: a runner scans a build tree, executes
whatever it finds, and adds up whatever those binaries print. JNEXT does the
opposite. test/unit-tests.conf lists every unit suite by name and states, for
each one, the exact number of rows it must report; the harness then proves that
what ran matches what was declared, and refuses to produce a result when it
does not.
The reason is the one that runs through this whole chapter. A discovered test run can only tell you that everything it found passed, which says nothing about what it did not find — and the failure mode nobody notices is a suite or a row quietly disappearing, because that makes the run greener, not redder. Pinning the numbers turns the denominator into a deliberate, reviewable claim: the file says how much this project tests, a human edits it when that changes, and any drift between the claim and reality stops the run. The file's own header puts it more briefly — this file is a contract, not a convenience.
A line¶
<executable> <expected_rows> [args...]
<executable>is the binary under<build>/test/. A leading?marks the suite optional, meaning a legitimate build configuration may not register it at all: thedebugger_*suites exist only under-DENABLE_DEBUGGER=ONandapp_config_testonly under-DENABLE_QT_UI=ON, so all of them are declared with?. When an optional suite is absent, the harness prints a NOTICE and counts it as not-run — skipped, but never silently.<expected_rows>is the exactTotal:the suite must report, and it must be at least 1. A pin of0is rejected outright at parse time, because a suite pinned at 0 that reports 0 rows would pass, whereas the same suite printing no summary at all is a hard failure. Zeroing a suite is precisely the silent truncation this file exists to forbid.[args...]are passed through to the binary.@BUILD@expands to the build directory, which is howfuse_z80_testandz80n_testlocate their data.- A
# expect: Nline pins how many suites the file declares.
Blank lines and # comments are ignored, and a duplicate entry is rejected.
Refuse to run — exit 2, before a single test executes¶
Before anything is executed, test/run-unit-tests.sh cross-checks the manifest
against what CMake actually registered. It reads that from every
CTestTestfile.cmake in the build tree, pruning nested build trees that have
their own CMakeCache.txt so that a stray build/gui-debug cannot make
everything look as though it were registered twice. It refuses when:
- a suite is declared here but not registered by CMake, which means the entry is stale;
- a suite is registered by CMake but not declared here, in which case it would never run;
- a suite is declared here but not built — there is no binary at
<build>/test/<name>; - a suite is declared twice, since a duplicate runs it twice and inflates every count that follows;
- one binary is registered under two
add_test()names, a shape the manifest cannot even express; - the parser read fewer
add_test()lines than the file contains, so it cannot vouch for the list it just built; - the
# expect: Npin is missing, or disagrees with the number of declared suites.
That last condition is the subtle one, and it is what makes the whole check
more than bookkeeping. Without it, "N declared equals N registered" is a
tautology against exactly the edit that matters most: delete a suite's
add_test() and its manifest row, and both sides shrink together, every
count agrees, the run exits 0, and the suite is simply gone. A review round did
precisely that with log_gate_test and got a clean green run for it. The
# expect: pin is the witness from outside that arithmetic.
Fail — exit 1, after the run¶
Once the suites have run, one fails when it:
- reports a row count other than the pinned one, in either direction — fewer rows means rows vanished, and more rows means the manifest needs a deliberate update;
- prints no parseable
Total:line, which is a failure even when the binary exits 0, because a suite that prints no summary asserted nothing; - crashes, exits non-zero, or contains any row that failed;
- times out, which by default means 300 s for a single suite.
make unit-test exits non-zero whenever a suite fails, and the harness prints
an explicit UNIT TESTS FAILED banner so that the grand-total line — the one
people copy into status reports — cannot be mistaken for a pass.
Why the number is edited by hand¶
Adding or removing a test row means editing that suite's count in this file. The manual edit is the point rather than an inconvenience: the number is the project's claim about how much it tests, and a claim should be made deliberately, not adopted from whatever a binary happened to print this afternoon.
The CMake side is different, and deliberately so. It is not a second
hand-kept list — it is read from the generated CTestTestfile.cmake. Only the
counts are human.
The three incidents¶
All three of these happened within a single day, and all three were found by accident rather than by any check:
- Task 32 —
cpu_int_pulse_testandcpu_z80n_im2_regressions_testhad been compiled and registered withadd_test()all along, but were absent from the hand-kept list that then lived inside the Makefile, so they had never run. That was 63 passing assertions nobody was counting. - Task 35 —
make cleandeletedbuild/test/rewind_test, and the suite then printed no row at all. It was not reported as a skip; the total simply went from 57 to 56 in silence. - Task 37 — a worktree with no
roms/madesd_rom_extractor_testreport 8 rows when it has 26. Eighteen rows ceased to exist and the run still looked green.
Other things the harness pins down¶
The run is parallel, so the aggregator has to survive a failing suite. Each
suite's exit code is captured with || rc=$? inside its subshell, because
inheriting set -e once killed that subshell before the code was recorded,
which dropped every suite after the failure from the run. Membership tests are
in-shell hash lookups rather than printf ... | grep -q, because under
pipefail that pipeline can report a suite that is present as absent about 4%
of the time on a loaded machine.
LC_ALL=C is exported for the whole run. Qt's QApplication constructor calls
setlocale(LC_ALL, ""), which localises strerror(), and a test's verdict has
to depend on the code rather than on who ran it. Finally, the harness clones the
machine-wide SD master into a private per-run directory and points
JNEXT_TEST_SD_IMAGE at the copy, so that a concurrent manual jnext session
writing back to the card cannot turn sd_rom_extractor_test red for reasons
that have nothing to do with the code under test.