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
9 changes: 8 additions & 1 deletion .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,14 @@ assert_file_not_exists "$path"
assert_directory_exists "$path"
assert_array_contains "value" "${array[@]}"
assert_array_not_contains "value" "${array[@]}"
assert_fails "assert_equals 'a' 'b'"
```

There is no `assert_fails`. To assert that an assertion *fails*, capture its output and
compare it with the renderer:

```bash
assert_same "$(bashunit::console_results::print_failed_test "My test" "a" "but got " "b")" \
"$(assert_same "a" "b")"
```

## Test Doubles
Expand Down
3 changes: 2 additions & 1 deletion .claude/skills/add-assertion/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ Read `src/assertions.sh` and `tests/unit/assert_test.sh` to understand:
For each test in inventory, follow RED -> GREEN -> REFACTOR:

1. **Basic success case** — assertion passes with valid input
2. **Failure case** — assertion fails correctly, use `assert_fails`
2. **Failure case** — assertion fails correctly: capture its output and compare it with
`bashunit::console_results::print_failed_test` (there is no `assert_fails`)
3. **Edge cases** — empty input, special characters, nested structures, malformed data

### 4. Integration
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- The `--parallel` unsupported-OS warning no longer claims Alpine is excluded

### Fixed
- Call assertions (`assert_not_called`, `assert_have_been_called*`) fail with `was never registered as a spy` instead of reporting zero calls when the name was never spied — a typo used to pass silently (#895)
- 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+**
Expand Down
3 changes: 3 additions & 0 deletions docs/ai-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ 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]`.
- A call assertion on a name that was never passed to `bashunit::spy` fails with
`was never registered as a spy`. Spies do not survive across tests, so register the
spy inside the test that asserts on it.
- `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
Expand Down
4 changes: 4 additions & 0 deletions docs/public/bashunit-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ failing_command || ec=$? # correct
local out; out=$(failing_command) # WRONG under set -e
```

**Spies do not survive across tests, and a call assertion on an unregistered name fails**
with `was never registered as a spy` (it used to report zero calls, so `assert_not_called`
with a typo passed while asserting nothing). Register the spy in the test that asserts on it.

**`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
Expand Down
12 changes: 12 additions & 0 deletions docs/test-doubles.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ function test_example() {
```
:::

::: tip Call assertions require a registered spy
Every `assert_have_been_called*` / `assert_not_called` assertion fails when the name it targets was never passed to `bashunit::spy` — or was removed by `bashunit::unmock`, or belongs to a previous test:

```
✗ Failed: My test
Expected 'tuoch'
was never registered as a spy; call it first with 'bashunit::spy tuoch'
```

Without that check a typo would report zero calls, so `assert_not_called tuoch` would pass while asserting nothing. Spies are cleared between tests, so spy inside the test that asserts on them.
:::

## assert_have_been_called
> `assert_have_been_called "spy"`

Expand Down
59 changes: 53 additions & 6 deletions src/test_doubles.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,37 @@ declare -a _BASHUNIT_MOCKED_FUNCTIONS=()
# resolves `_BASHUNIT_SPY_foo_TIMES_FILE` instead.

_BASHUNIT_SPY_TIMES_OUT=0
_BASHUNIT_SPY_REGISTERED_OUT=false

# Reads the recorded call count for $1 into _BASHUNIT_SPY_TIMES_OUT (0 when
# never spied/called). Shared by the call-count assertions instead of each
# repeating the "resolve times file, cat it, default to 0" sequence.
# never called) and whether $1 is a live spy into _BASHUNIT_SPY_REGISTERED_OUT.
# Shared by the call-count assertions instead of each repeating the "resolve
# times file, cat it, default to 0" sequence.
function bashunit::spy::times_to_slot() {
local command="$1"
local variable
variable="$(bashunit::helper::normalize_variable_name "$command")"
local file_var="_BASHUNIT_SPY_${variable}_TIMES_FILE"
_BASHUNIT_SPY_TIMES_OUT=0
_BASHUNIT_SPY_REGISTERED_OUT=false
if [ -n "${!file_var-}" ]; then
_BASHUNIT_SPY_REGISTERED_OUT=true
fi
if [ -f "${!file_var-}" ]; then
_BASHUNIT_SPY_TIMES_OUT=$(cat "${!file_var}" 2>/dev/null || builtin echo 0)
fi
}

# Fails the calling assertion because $1 is not a registered spy. Reporting
# "never called" for a name that was never spied — a typo, or a spy that was
# unmocked — makes the assertion vacuous while still printing green.
# Arguments: $1 - command, $2 - test label
function bashunit::spy::fail_unregistered() {
bashunit::state::add_assertions_failed
bashunit::console_results::print_failed_test "$2" "$1" \
"was never registered as a spy; call it first with" "bashunit::spy $1"
}

_BASHUNIT_SPY_SERIALIZED_OUT=""

# Serializes the given arguments into _BASHUNIT_SPY_SERIALIZED_OUT exactly the
Expand Down Expand Up @@ -137,6 +153,11 @@ function assert_have_been_called() {
local times=$_BASHUNIT_SPY_TIMES_OUT
local label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}"

if [ "$_BASHUNIT_SPY_REGISTERED_OUT" = false ]; then
bashunit::spy::fail_unregistered "$command" "$label"
return
fi

if [ "$times" -eq 0 ]; then
bashunit::state::add_assertions_failed
bashunit::console_results::print_failed_test "${label}" "${command}" "to have been called" "once"
Expand Down Expand Up @@ -166,6 +187,14 @@ function assert_have_been_called_with() {
local variable
variable="$(bashunit::helper::normalize_variable_name "$command")"
local file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE"
local label
label="$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")"

if [ -z "${!file_var-}" ]; then
bashunit::spy::fail_unregistered "$command" "$label"
return
fi

local line=""
if [ -f "${!file_var-}" ]; then
if [ -n "$index" ]; then
Expand All @@ -180,8 +209,7 @@ function assert_have_been_called_with() {

if [ "$expected" != "$raw" ]; then
bashunit::state::add_assertions_failed
bashunit::console_results::print_failed_test "$(bashunit::helper::normalize_test_function_name \
"${FUNCNAME[1]}")" "$expected" "but got " "$raw"
bashunit::console_results::print_failed_test "$label" "$expected" "but got " "$raw"
return
fi

Expand All @@ -198,6 +226,14 @@ function assert_have_been_called_with_args() {
local variable
variable="$(bashunit::helper::normalize_variable_name "$command")"
local file_var="_BASHUNIT_SPY_${variable}_PARAMS_FILE"
local label
label="$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")"

if [ -z "${!file_var-}" ]; then
bashunit::spy::fail_unregistered "$command" "$label"
return
fi

local line=""
if [ -f "${!file_var-}" ]; then
line=$(tail -n 1 "${!file_var}" 2>/dev/null || true)
Expand All @@ -208,8 +244,8 @@ function assert_have_been_called_with_args() {

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'/ }"
bashunit::console_results::print_failed_test "$label" \
"${expected//$'\x1f'/ }" "but got " "${actual//$'\x1f'/ }"
return
fi

Expand All @@ -222,6 +258,12 @@ function assert_have_been_called_times() {
bashunit::spy::times_to_slot "$command"
local times=$_BASHUNIT_SPY_TIMES_OUT
local label="${3:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}"

if [ "$_BASHUNIT_SPY_REGISTERED_OUT" = false ]; then
bashunit::spy::fail_unregistered "$command" "$label"
return
fi

if [ "$times" -ne "$expected_count" ]; then
bashunit::state::add_assertions_failed
bashunit::console_results::print_failed_test "${label}" "${command}" \
Expand All @@ -248,6 +290,11 @@ function assert_have_been_called_nth_with() {
bashunit::spy::times_to_slot "$command"
local times=$_BASHUNIT_SPY_TIMES_OUT

if [ "$_BASHUNIT_SPY_REGISTERED_OUT" = false ]; then
bashunit::spy::fail_unregistered "$command" "$label"
return
fi

if [ "$nth" -gt "$times" ]; then
bashunit::state::add_assertions_failed
bashunit::console_results::print_failed_test "${label}" \
Expand Down
7 changes: 6 additions & 1 deletion tests/acceptance/mock_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,10 @@ function test_runner_clear_mocks_first() {

function test_runner_clear_mocks_second() {
assert_not_equals "foo" "$(ls)"
assert_have_been_called_times 0 ps
# The spy registered by the first test is gone, so a call assertion on `ps`
# now reports an unregistered spy rather than zero calls.
assert_same \
"$(bashunit::console_results::print_failed_test "Runner clear mocks second" "ps" \
"was never registered as a spy; call it first with" "bashunit::spy ps")" \
"$(assert_have_been_called_times 0 ps)"
}
42 changes: 42 additions & 0 deletions tests/functional/doubles_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,48 @@ function test_spy_call_with_args_detects_wrong_argument_boundaries() {
"$(assert_have_been_called_with_args spy_boundary_command "a" "b")"
}

function test_assert_not_called_fails_when_the_command_was_never_spied() {
assert_same \
"$(bashunit::console_results::print_failed_test \
"Assert not called fails when the command was never spied" \
"never_spied_command" "was never registered as a spy; call it first with" \
"bashunit::spy never_spied_command")" \
"$(assert_not_called never_spied_command)"
}

function test_call_assertions_fail_when_the_command_was_never_spied() {
local label="Call assertions fail when the command was never spied"
local expected
expected="$(bashunit::console_results::print_failed_test "$label" \
"never_spied_command" "was never registered as a spy; call it first with" \
"bashunit::spy never_spied_command")"

assert_same "$expected" "$(assert_have_been_called never_spied_command)"
assert_same "$expected" "$(assert_have_been_called_times 0 never_spied_command)"
assert_same "$expected" "$(assert_have_been_called_with never_spied_command "x")"
assert_same "$expected" "$(assert_have_been_called_with_args never_spied_command "x")"
assert_same "$expected" "$(assert_have_been_called_nth_with 1 never_spied_command "x")"
}

function test_call_assertions_fail_after_the_spy_was_unmocked() {
bashunit::spy spy_to_be_unmocked
bashunit::unmock spy_to_be_unmocked

assert_same \
"$(bashunit::console_results::print_failed_test \
"Call assertions fail after the spy was unmocked" \
"spy_to_be_unmocked" "was never registered as a spy; call it first with" \
"bashunit::spy spy_to_be_unmocked")" \
"$(assert_not_called spy_to_be_unmocked)"
}

function test_assert_not_called_still_passes_on_a_registered_spy() {
bashunit::spy spy_never_invoked

assert_empty "$(assert_not_called spy_never_invoked)"
assert_empty "$(assert_have_been_called_times 0 spy_never_invoked)"
}

function test_spy_on_echo_does_not_hang() {
source ./tests/functional/fixtures/echo_function.sh
bashunit::spy echo
Expand Down
Loading