Summary
Call a two-argument assertion with one argument and it does not complain. It compares against the empty string and reports an ordinary failure:
function test_wrong_arg_count() {
assert_same "only-one"
}
✗ Failed: Wrong arg count
Expected 'only-one'
but got ''
That output is indistinguishable from a genuine failure where the code under test really did produce an empty string. The reader debugs their code; the bug is in the test.
Why this is the expensive kind of mistake
Empty is the single most common real value in shell — an unset variable, a command that printed nothing, a failed capture. So the false story this tells is also the most plausible one. Someone hitting it will go and check why their function returned nothing, because that is exactly what the message says happened.
It is also easy to reach: assert_same "$expected" after deleting the second argument during a refactor, or a typo'd "$actual" that expands to nothing and silently collapses the argument count.
Related and worth handling together: argument order in this catalogue is not uniform, so a swapped pair is another way to produce a confusing-but-plausible failure. assert_same and assert_contains take expected/needle first; assert_json_contains takes key expected json with the subject last. I hit that myself while researching this — assert_json_contains '{"a":[1,2,3]}' '.a|length' "3" reports Expected '.a | length' but got '{…', which is the assertion faithfully comparing the two arguments it was handed. Nothing is wrong except that no one can tell.
Proposal
Arity checking on the assertion entry points. When too few arguments are supplied, fail with a usage error rather than a comparison:
✗ Error: Wrong arg count
assert_same expects 2 arguments (expected, actual), got 1
Design points worth settling before implementing:
- Report as an error, not a failure. This is a defect in the test, the same class as calling an assertion that does not exist — which this framework already surfaces as
Error, not Failed. Reusing that channel keeps the distinction the reader needs.
- Only under-supply, not over-supply. Several assertions take a trailing optional argument (a label override, an
nth index), so a strict upper bound would be wrong or would need per-assertion tables. Under-supply is unambiguous and covers the real failure mode.
- Do not use
$# naively where an assertion legitimately accepts an empty string as its second argument — assert_empty "$x" and assert_same "" "$x" are valid. The check is on count supplied, which $# gives correctly, so this works; it just has to be $# and never [ -z "$2" ].
Doing this for all 76 assertions at once is a large mechanical change. A reasonable first cut is the comparison family that carries the risk: assert_same, assert_equals, assert_not_same, assert_not_equals, assert_contains, assert_not_contains, assert_greater_than and friends.
Constraints
- Bash 3.0+;
$# and case only, no compatibility surface.
- Per-assertion path must stay fork-free — see
.claude/rules/perf-fork-budget.md. An arity check is a builtin comparison, so this is free, but it must not become a shared helper invoked through $( ).
- Public API: no signature changes. A call that is correct today must behave identically — the only behaviour change is for calls that are already broken.
- The error path needs its own rendering; check how
bashunit::assert::fail_with and the Error classification in runner/diagnostics.sh interact before inventing a third shape.
CHANGELOG.md under ### Changed, and note it explicitly: a suite that was silently passing a malformed assertion will start reporting it.
Acceptance criteria
Summary
Call a two-argument assertion with one argument and it does not complain. It compares against the empty string and reports an ordinary failure:
That output is indistinguishable from a genuine failure where the code under test really did produce an empty string. The reader debugs their code; the bug is in the test.
Why this is the expensive kind of mistake
Empty is the single most common real value in shell — an unset variable, a command that printed nothing, a failed capture. So the false story this tells is also the most plausible one. Someone hitting it will go and check why their function returned nothing, because that is exactly what the message says happened.
It is also easy to reach:
assert_same "$expected"after deleting the second argument during a refactor, or a typo'd"$actual"that expands to nothing and silently collapses the argument count.Related and worth handling together: argument order in this catalogue is not uniform, so a swapped pair is another way to produce a confusing-but-plausible failure.
assert_sameandassert_containstake expected/needle first;assert_json_containstakeskey expected jsonwith the subject last. I hit that myself while researching this —assert_json_contains '{"a":[1,2,3]}' '.a|length' "3"reportsExpected '.a | length' but got '{…', which is the assertion faithfully comparing the two arguments it was handed. Nothing is wrong except that no one can tell.Proposal
Arity checking on the assertion entry points. When too few arguments are supplied, fail with a usage error rather than a comparison:
Design points worth settling before implementing:
Error, notFailed. Reusing that channel keeps the distinction the reader needs.nthindex), so a strict upper bound would be wrong or would need per-assertion tables. Under-supply is unambiguous and covers the real failure mode.$#naively where an assertion legitimately accepts an empty string as its second argument —assert_empty "$x"andassert_same "" "$x"are valid. The check is on count supplied, which$#gives correctly, so this works; it just has to be$#and never[ -z "$2" ].Doing this for all 76 assertions at once is a large mechanical change. A reasonable first cut is the comparison family that carries the risk:
assert_same,assert_equals,assert_not_same,assert_not_equals,assert_contains,assert_not_contains,assert_greater_thanand friends.Constraints
$#andcaseonly, no compatibility surface..claude/rules/perf-fork-budget.md. An arity check is a builtin comparison, so this is free, but it must not become a shared helper invoked through$( ).bashunit::assert::fail_withand theErrorclassification inrunner/diagnostics.shinteract before inventing a third shape.CHANGELOG.mdunder### Changed, and note it explicitly: a suite that was silently passing a malformed assertion will start reporting it.Acceptance criteria
assert_same "only-one"reports a usage error naming the expected argument count, notbut got ''Error, consistent with an undefined assertion, not asFailedassert_same "" "$x"and other legitimately-empty arguments still worknth) are unaffectedmake sa·make lint·./bashunit --parallel --simple --strict tests/·bash build.sh bin -v