diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index cad4d713..b9c617b5 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -49,6 +49,7 @@ assert_have_been_called_times 2 function_name # count first, then spy assert_have_been_called_with function_name "arg" # spy first, then expected assert_have_been_called_with function_name "arg" 1 # ...of call #1 assert_have_been_called_nth_with 1 function_name "arg" +assert_have_been_called_with_args function_name "a b" # boundary-exact, no call index # Mocks - replace behavior bashunit::mock date echo "2024-05-01" diff --git a/CHANGELOG.md b/CHANGELOG.md index be76c83e..9ae0196a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- `assert_have_been_called_with_args ...`: compares the recorded arguments one by one, so `cmd "a b"` no longer matches `cmd a b` (#894) - `BASHUNIT_COVERAGE_ENGINE=auto|xtrace|trap`: a new `xtrace` coverage engine, ~4x cheaper per captured line. Needs Bash 4.1+, so `auto` (the default) falls back to `trap` below that (#860) - `BASHUNIT_COVERAGE_SHOW_LINE_HITS=true` prints per-line execution counts in the text coverage report (#856) - An [Agentic coding](https://bashunit.com/ai-agents) docs page, and `llms.txt` / `llms-full.txt` are now linked @@ -18,6 +19,7 @@ - The `--parallel` unsupported-OS warning no longer claims Alpine is excluded ### Fixed +- The per-argument form a spy records was written with a literal `$'\x1f'` separator instead of the byte, so it could not be compared against (#894) - `--parallel` no longer discards worker stderr written outside a test body; it renders as a `Stderr from ` block (#864) - The minimum-bash gate compares the minor version and parses suffixed versions; the floor is unchanged at **Bash 3.0+** - An empty entry in `.env` no longer blanks a value the caller exported or passed on the command line (#865) diff --git a/docs/ai-agents.md b/docs/ai-agents.md index bb303dd3..1567d19b 100644 --- a/docs/ai-agents.md +++ b/docs/ai-agents.md @@ -114,6 +114,10 @@ actually make against this API: - Spy assertion argument order is inconsistent — check, don't guess: `assert_have_been_called_times ` but `assert_have_been_called_with [call_index]`. +- `assert_have_been_called_with` joins the expected arguments with spaces, so it cannot + tell `cmd "a b"` from `cmd a b` — a quoting bug passes it. Use + `assert_have_been_called_with_args ...` (no `call_index`) to compare + argument by argument whenever an argument may contain a space. - Do not delete a shared fixture in `tear_down_after_script`: under `--parallel` the file's tests may still be running, and they will vanish from the totals silently. - There is no `--update-snapshots` flag. Re-record a snapshot by deleting the file and diff --git a/docs/assertions.md b/docs/assertions.md index b5713375..6b6e010e 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -29,7 +29,7 @@ to narrow it (`bashunit doc json`). | **JSON** | [assert_json_equals](#assert-json-equals) · [assert_json_contains](#assert-json-contains) · [assert_json_key_exists](#assert-json-key-exists) | | **Duration** | [assert_duration](#assert-duration) · [assert_duration_less_than](#assert-duration-less-than) · [assert_duration_greater_than](#assert-duration-greater-than) | | **Snapshots** | [assert_match_snapshot](#assert-match-snapshot) · [assert_match_snapshot_ignore_colors](#assert-match-snapshot-ignore-colors) | -| **Spies** | [assert_have_been_called](#assert-have-been-called) · [assert_not_called](#assert-not-called) · [assert_have_been_called_with](#assert-have-been-called-with) · [assert_have_been_called_nth_with](#assert-have-been-called-nth-with) · [assert_have_been_called_times](#assert-have-been-called-times) | +| **Spies** | [assert_have_been_called](#assert-have-been-called) · [assert_not_called](#assert-not-called) · [assert_have_been_called_with](#assert-have-been-called-with) · [assert_have_been_called_with_args](#assert-have-been-called-with-args) · [assert_have_been_called_nth_with](#assert-have-been-called-nth-with) · [assert_have_been_called_times](#assert-have-been-called-times) | | **Manual failure** | [bashunit::fail](#bashunit-fail) | ## assert_true @@ -1679,6 +1679,31 @@ function test_failure() { ``` ::: +## assert_have_been_called_with_args +> `assert_have_been_called_with_args "command" "expected_arg"...` + +Reports an error if the **last** call to the spied `command` did not receive exactly these arguments. Unlike [assert_have_been_called_with](#assert-have-been-called-with), the arguments are compared one by one, so `cmd "a b"` does not match `cmd a b`. Use it whenever an argument may contain spaces, such as a path. + +There is no `nth` parameter: a trailing number would be indistinguishable from a numeric argument. + +::: code-group +```bash [Example] +function test_success() { + bashunit::spy touch + create_report "/tmp/my reports" + + assert_have_been_called_with_args touch "/tmp/my reports/out.txt" +} + +function test_failure() { + bashunit::spy touch + create_report "/tmp/my reports" + + assert_have_been_called_with_args touch "/tmp/my" "reports/out.txt" +} +``` +::: + ## assert_have_been_called_times > `assert_have_been_called_times "expected_count" "command"` diff --git a/docs/public/bashunit-skill.md b/docs/public/bashunit-skill.md index 7747cefe..bce8ab90 100644 --- a/docs/public/bashunit-skill.md +++ b/docs/public/bashunit-skill.md @@ -134,7 +134,8 @@ filters it. The same list is at https://bashunit.com/assertions. **Do not invent - JSON: `assert_json_equals`, `assert_json_contains`, `assert_json_key_exists` - Snapshots: `assert_match_snapshot`, `assert_match_snapshot_ignore_colors` - Spies: `assert_have_been_called`, `assert_not_called`, `assert_have_been_called_with`, - `assert_have_been_called_times`, `assert_have_been_called_nth_with` + `assert_have_been_called_with_args`, `assert_have_been_called_times`, + `assert_have_been_called_nth_with` `assert_same` compares exactly. `assert_equals` first strips ANSI colour codes, tabs and newlines — useful for asserting on coloured CLI output, misleading everywhere else. @@ -164,6 +165,11 @@ failing_command || ec=$? # correct local out; out=$(failing_command) # WRONG under set -e ``` +**`assert_have_been_called_with` joins arguments with spaces**, so it cannot see argument +boundaries: `touch "a b"` also satisfies `assert_have_been_called_with touch "a" "b"`. When +an argument may contain a space — any path — use `assert_have_been_called_with_args`, which +compares argument by argument. + **A test with no assertions passes.** Always assert something; `--fail-on-risky` turns that into a failure. @@ -191,6 +197,7 @@ assert_have_been_called_times 2 send_email # count FIRST, then spy assert_have_been_called_with send_email "--to a@b.c" # spy FIRST, then expected assert_have_been_called_with send_email "--to a@b.c" 1 # ...of call #1 assert_have_been_called_nth_with 1 send_email "--to a@b.c" +assert_have_been_called_with_args send_email "--to" "a@b.c" # boundary-exact, no index ``` The argument order is inconsistent between `_times` and `_with`. A swapped pair *fails diff --git a/docs/test-doubles.md b/docs/test-doubles.md index be736dab..98bfae25 100644 --- a/docs/test-doubles.md +++ b/docs/test-doubles.md @@ -178,6 +178,34 @@ function test_failure() { ::: +## assert_have_been_called_with_args +> `assert_have_been_called_with_args spy expected...` + +Reports an error if the last invocation of `spy` did not receive exactly these arguments. Arguments are compared one by one instead of being joined with spaces, so an argument containing a space cannot be confused with two arguments — the check `assert_have_been_called_with` cannot make. + +There is no `call_index` parameter: a trailing number could not be told apart from a numeric argument. + +::: code-group +```bash [Example] +function test_success() { + bashunit::spy touch + + touch "a b" + + assert_have_been_called_with_args touch "a b" +} + +function test_failure() { + bashunit::spy touch + + touch "a b" + + # passes with assert_have_been_called_with, fails here + assert_have_been_called_with_args touch "a" "b" +} +``` +::: + ## assert_have_been_called_nth_with > `assert_have_been_called_nth_with "nth" "spy" "expected"` diff --git a/src/test_doubles.sh b/src/test_doubles.sh index 5146363e..0cf23d5e 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -23,6 +23,21 @@ function bashunit::spy::times_to_slot() { fi } +_BASHUNIT_SPY_SERIALIZED_OUT="" + +# Serializes the given arguments into _BASHUNIT_SPY_SERIALIZED_OUT exactly the +# way bashunit::spy records a call: each argument through `printf '%q'`, joined +# with \x1f. Unlike the space-joined `raw` field, this keeps the argument +# boundaries, so `f "a b"` and `f a b` serialize differently. +function bashunit::spy::serialize_args_to_slot() { + local serialized="" + local arg + for arg in "$@"; do + serialized="$serialized$(builtin printf '%q' "$arg")"$'\x1f' + done + _BASHUNIT_SPY_SERIALIZED_OUT=${serialized%$'\x1f'} +} + function bashunit::unmock() { local command=$1 @@ -100,7 +115,7 @@ function bashunit::spy() { local serialized=\"\" local arg for arg in \"\$@\"; do - serialized=\"\$serialized\$(builtin printf '%q' \"\$arg\")$'\\x1f'\" + serialized=\"\$serialized\$(builtin printf '%q' \"\$arg\")\"$'\\x1f' done serialized=\${serialized%$'\\x1f'} builtin printf '%s\x1e%s\\n' \"\$raw\" \"\$serialized\" >> '$params_file' @@ -173,6 +188,34 @@ function assert_have_been_called_with() { bashunit::state::add_assertions_passed } +function assert_have_been_called_with_args() { + local command=$1 + shift + + bashunit::spy::serialize_args_to_slot "$@" + local expected=$_BASHUNIT_SPY_SERIALIZED_OUT + + local variable + variable="$(bashunit::helper::normalize_variable_name "$command")" + local file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE" + local line="" + if [ -f "${!file_var-}" ]; then + line=$(tail -n 1 "${!file_var}" 2>/dev/null || true) + fi + + local actual + IFS=$'\x1e' read -r _ actual <<<"$line" || true + + if [ "$expected" != "$actual" ]; then + bashunit::state::add_assertions_failed + bashunit::console_results::print_failed_test "$(bashunit::helper::normalize_test_function_name \ + "${FUNCNAME[1]}")" "${expected//$'\x1f'/ }" "but got " "${actual//$'\x1f'/ }" + return + fi + + bashunit::state::add_assertions_passed +} + function assert_have_been_called_times() { local expected_count=$1 local command=$2 diff --git a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot index 86287445..b6086a3c 100644 --- a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot +++ b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot @@ -651,6 +651,15 @@ Reports an error if the spied `command` was not called with `expected_args`. Che Note the argument order: the spy comes first here, but *second* in assert_have_been_called_times. +## assert_have_been_called_with_args +-------------- +> `assert_have_been_called_with_args "command" "expected_arg"...` + +Reports an error if the **last** call to the spied `command` did not receive exactly these arguments. Unlike assert_have_been_called_with, the arguments are compared one by one, so `cmd "a b"` does not match `cmd a b`. Use it whenever an argument may contain spaces, such as a path. + +There is no `nth` parameter: a trailing number would be indistinguishable from a numeric argument. + + ## assert_have_been_called_times -------------- > `assert_have_been_called_times "expected_count" "command"` diff --git a/tests/functional/doubles_test.sh b/tests/functional/doubles_test.sh index cc0a01d1..4ba6b86a 100644 --- a/tests/functional/doubles_test.sh +++ b/tests/functional/doubles_test.sh @@ -107,6 +107,26 @@ function test_mock_mktemp_does_not_break_spy_creation() { assert_have_been_called_with rm "-f" "/tmp/mocked_temp_file" } +function test_spy_call_with_args_matches_exact_argument_boundaries() { + bashunit::spy spy_boundary_command + + spy_boundary_command "a b" + + assert_empty "$(assert_have_been_called_with_args spy_boundary_command "a b")" +} + +function test_spy_call_with_args_detects_wrong_argument_boundaries() { + bashunit::spy spy_boundary_command + + spy_boundary_command "a b" + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Spy call with args detects wrong argument boundaries" \ + "a b" "but got " "a\\ b")" \ + "$(assert_have_been_called_with_args spy_boundary_command "a" "b")" +} + function test_spy_on_echo_does_not_hang() { source ./tests/functional/fixtures/echo_function.sh bashunit::spy echo diff --git a/tests/unit/test_doubles_test.sh b/tests/unit/test_doubles_test.sh index f8bdc686..f30521ad 100644 --- a/tests/unit/test_doubles_test.sh +++ b/tests/unit/test_doubles_test.sh @@ -135,6 +135,12 @@ function test_mock_called_in_subshell() { assert_same "2024-05-01" "$result" } +function test_spy_serialize_args_joins_quoted_arguments_with_a_unit_separator() { + bashunit::spy::serialize_args_to_slot "a b" c + + assert_same "a\\ b"$'\x1f'"c" "$_BASHUNIT_SPY_SERIALIZED_OUT" +} + function test_spy_called_with_different_arguments() { bashunit::spy ps