mando e2e run --junit and the new mando verify --junit now emit one JUnit testcase per assertion instead of one per test set, so GitLab’s MR widget names the assertion that broke rather than the suite that contained it. Branch feature/junit-per-assertion, commit 9c39a88, repo /Volumes/bandi/coding/poc/mando-cli. This closes the open improvement flagged in Known improvement — junit granularity.
For Agents
User-facing reference (flags, classname scheme, sample XML) lives in the repo at
docs/e2e-guide.md→ “JUnit report —--junit <path>”. This note deliberately does not duplicate it; it records the reasoning, the invariants, and the traps.
Shipped and verified in CI — 2026-08-06
9c39a88was pushed directly tomainandrelease(per user instruction) and released through the release branch. Confirmed live in mando pipeline2735668937: the GitLab Tests tab renders 8 named cases, 0 failed for suite E2E Data Suite Linux Dev — sample namesstep load_battery_timeseries is Success,no unmatched requests,POST /ExternalData/DataGroups at most 0x. The change added 20 unit tests (1632 in the suite).
The fix was a plumbing fix, not new evaluation logic
The most useful thing to know before touching this area: the verify engine was already per-assertion. src/e2e/verify.rs evaluates every expectation separately and builds a Vec<Assertion>. The report collapsed for two purely mechanical reasons:
| Where | What it did | What it does now |
|---|---|---|
verify::run (src/e2e/verify.rs) | discarded the Vec<Assertion> and returned a bare bool | returns the assertions it evaluated |
junit_cases (src/commands/e2e.rs) | mapped one SetResult → one TestCase | flat-maps each set’s assertions into cases |
Generalizable
When a report is coarser than the engine behind it, check whether the granularity was thrown away at a boundary before assuming it was never computed. Here the whole feature was “stop returning
bool”.
The dual-label pattern
Every Assertion now carries two names, constructed together by a Label type in src/e2e/verify.rs:
pub struct Label {
pub section: &'static str,
pub name: String, // terminal
pub case: String, // JUnit
}name— the machine label the terminal prints, e.g.mock.requests[GET /path].case— the human sentence JUnit names the test with, e.g.GET /path at least 1x.
They are separate on purpose: the terminal label must stay byte-identical for humans reading a run and diffing output, while the JUnit label answers to GitLab’s history-tracking rules (below). Collapsing them into one string would force one consumer’s stability requirement onto the other.
Label::dotted(section, suffix, case) is the helper for the common section.suffix terminal form.
The non-obvious constraint — case names come from the EXPECTATION, never the result
The rule
A JUnit case name must be derivable before the assertion is evaluated. Never build it from the observed value.
GitLab attaches a test’s history to the pair (classname, name). If the label changed between a passing run and a failing run, GitLab does not see a pass → fail transition — it sees one test disappearing and a different test appearing. The MR widget’s “newly failed / previously failed” classification, and the test’s flakiness history, both evaporate.
Concretely: the label for a step expectation reads step X is Success taken from the expected status, not from whatever the step actually did. Likewise GET /path at least 1x comes from the expectation’s min, not the observed request count.
There is a regression test pinning this invariant:
a_case_label_does_not_move_when_its_assertion_flips_to_failing (src/e2e/verify.rs)
Anyone adding a new assertion type must keep that test meaningful — build the case string from the expectation struct only.
The false-green trap
A test set that never reaches the verify engine produces zero assertions. Two ways that happens:
- it was skipped via
skip:incase.yaml; - it failed earlier — while running its flow — and never got to verification.
If those emitted nothing, the JUnit report would be green by omission: a suite where every set died before verifying would upload a file containing no failures.
set_cases in src/commands/e2e.rs handles exactly this — when a set yields no assertion-derived cases, it emits one fallback case carrying the set’s own verdict (Pass/Fail/Skip):
/// A set that never reached the verify engine — skipped, or failed while
/// running the flow — has no assertions to report, and still needs a case of
/// its own so the report does not read as green.Note the asymmetry that is correct: sections simply absent from case.yaml are never evaluated and emit nothing at all. Absence of an expectation is not a missing test; absence of a whole set’s execution is.
Classnames
| Command | Classname |
|---|---|
mando e2e run | <test set>.<section> — e.g. test_set_1.mock |
mando verify | bare <section> — no test set exists to scope with |
Superseded by the flow-scope change (
fabc589)
mando e2e runclassnames now derive from the case’sflow:field, not its directory:data-update.mock. The directory name survives only as a collision-disambiguation fallback when two cases target the same flow. Consequence, verified across pipelines2735716225and2736559170: renaming a case directory (test_set_1→data-update-beskar-soc) moves no(classname, name)pair and orphans no test history. See mando-cli-flow-coverage-2026-08-06.
mando verify gained --junit in this same change (src/cli.rs), so a standalone expectation file can now feed a CI report without going through the suite runner.
Terminal output and exit codes are unchanged by this commit.
Known gap — the always-green mock assertion
Deliberately NOT fixed; flagged for a future change
A
mock.requestsentry incase.yamlwith none ofcount/min/maxasserts nothing and always passes. Thegroncounts section guards against this shape;mockdoes not.
This is pre-existing behaviour, but per-assertion reporting makes it worse in appearance: the vacuous expectation now surfaces in the GitLab Tests tab as a named, green testcase labelled ... any number of times. It looks like real coverage in the MR widget while asserting nothing.
Fix direction when it is scheduled: reject a mock.requests entry with no count constraint at suite validation time, matching how gron counts already behave.
Related
- mando-e2e-ci-green-2026-08-05 — the CI job whose “one junit case per test set” limitation this change removes; also the source of the
artifacts: when: always+reports: junit:convention. - mando-cli-e2e-harness-2026-08-04 — the harness design;
verify’s section model (flow,mock,logs,spans,datapoints,outbound) is what the classnames are built from. - mando-cli-e2e-live-green-2026-08-05 — the live green run that made per-assertion reporting worth building.
- bess-os-ci-components — the Python-side junit reporting shape (
--junitxml+junitparsermerge) this mirrors. - mando-cli-gitlab-release-flow-2026-08-05 — how the mando-cli binary consuming these flags gets released.
- mando-cli — CLI overview.