Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

### Added
- `assert_have_been_called_with_args <spy> <arg>...`: 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
Expand All @@ -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 <file>` 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)
Expand Down
4 changes: 4 additions & 0 deletions docs/ai-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ actually make against this API:
- Spy assertion argument order is inconsistent — check, don't guess:
`assert_have_been_called_times <count> <spy>` but
`assert_have_been_called_with <spy> <expected> [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 <spy> <arg>...` (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
Expand Down
27 changes: 26 additions & 1 deletion docs/assertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`

Expand Down
9 changes: 8 additions & 1 deletion docs/public/bashunit-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions docs/test-doubles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
45 changes: 44 additions & 1 deletion src/test_doubles.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
20 changes: 20 additions & 0 deletions tests/functional/doubles_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions tests/unit/test_doubles_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading