From 27a2ade18ee88c1aa56be87516b7827c51839854 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 27 Jul 2026 22:22:56 +0200 Subject: [PATCH] feat(doubles): let bashunit::mock accept an exit code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bashunit::spy cmd 1` returns 1, but `bashunit::mock` had no equivalent: making a mocked command fail meant a throwaway `return 1` helper, or an inline body that exits with the status of whatever it ran rather than the simulated one. Read a lone all-digits argument as an exit code, using the same predicate the spy uses — now extracted so both doubles share one convention and one place where the interpolated value is proven numeric. Every existing form is unchanged: a numeric argument only counts when it is the single argument, so `bashunit::mock cmd echo 1` still prints 1 and exits 0. Note the issue's "command named 42" case is not reachable: bash rejects an all-digits function name, so such a command cannot be mocked at all. The argument-position guard is covered by the multi-argument test instead. Closes #898 --- CHANGELOG.md | 1 + docs/public/bashunit-skill.md | 1 + docs/test-doubles.md | 17 ++++++++++++++ src/test_doubles.sh | 27 +++++++++++++--------- tests/functional/doubles_test.sh | 39 ++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60dd5131..74f7a997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- `bashunit::mock `: a lone all-digits argument is an exit code, matching `bashunit::spy`; no throwaway `return 1` helper needed (#898) - `assert_have_been_called_with_any `: passes when *any* recorded call matches, instead of only the last one (#897) - A failed call assertion now prints the calls recorded for that spy, capped at 10 with an explicit `… and N more` (#896) - `assert_have_been_called_with_args ...`: compares the recorded arguments one by one, so `cmd "a b"` no longer matches `cmd a b` (#894) diff --git a/docs/public/bashunit-skill.md b/docs/public/bashunit-skill.md index 742c8f3e..92da8e67 100644 --- a/docs/public/bashunit-skill.md +++ b/docs/public/bashunit-skill.md @@ -198,6 +198,7 @@ diff and decided the new output is correct. ```bash bashunit::mock date echo "2024-05-01" # replace behaviour bashunit::mock uname <<< "Linux" # heredoc form ignores the call's arguments +bashunit::mock curl 1 # all-digits arg = exit code, no output (as for spy) bashunit::spy send_email # record calls, keep behaviour assert_have_been_called send_email diff --git a/docs/test-doubles.md b/docs/test-doubles.md index 0473c094..e985ccaf 100644 --- a/docs/test-doubles.md +++ b/docs/test-doubles.md @@ -63,6 +63,23 @@ EOF ``` ::: +A lone all-digits argument is an exit code, not an implementation — the same convention `bashunit::spy` uses. The mock then produces no output and returns that code, which is all an error-path test usually needs: + +::: code-group +```bash [Example] +function test_example() { + bashunit::mock curl 1 + + local code=0 + curl https://example.com || code=$? + + assert_same "1" "$code" +} +``` +::: + +To produce output *and* a non-zero exit code, pass a function: `bashunit::mock curl my_failing_curl`. + Mocked functions are also available inside subshells: ::: code-group diff --git a/src/test_doubles.sh b/src/test_doubles.sh index 7f9161ab..82e6c8c4 100644 --- a/src/test_doubles.sh +++ b/src/test_doubles.sh @@ -173,11 +173,24 @@ function bashunit::unmock() { done } +# True when $1 is an exit code rather than a replacement implementation, i.e. a +# non-empty all-digits string. Shared by both doubles so they read the same +# convention, and so the value interpolated into `return $1` is provably +# numeric — that is what keeps the generated body free of shell syntax. +function bashunit::doubles::is_exit_code() { + case "$1" in + '' | *[!0-9]*) return 1 ;; + esac + return 0 +} + function bashunit::mock() { local command=$1 shift - if [ $# -gt 0 ]; then + if [ $# -eq 1 ] && bashunit::doubles::is_exit_code "$1"; then + eval "function $command() { return $1; }" + elif [ $# -gt 0 ]; then eval "function $command() { $* \"\$@\"; }" else eval "function $command() { builtin echo \"$($CAT)\" ; }" @@ -204,17 +217,9 @@ function bashunit::spy() { export "_BASHUNIT_SPY_${variable}_PARAMS_FILE"="$params_file" # An all-digits second argument is an exit code; anything else non-empty is a - # replacement implementation. The `case` glob is the Bash 3.0 form of the old - # `[[ =~ ^[0-9]+$ ]]` (identical domain: a value is all-digits iff it is - # non-empty and contains no non-digit) and it keeps the interpolation below - # provably numeric, so `return $exit_code_or_impl` cannot inject shell syntax. + # replacement implementation. local body_suffix="" - local _is_exit_code=false - case "$exit_code_or_impl" in - '' | *[!0-9]*) ;; - *) _is_exit_code=true ;; - esac - if [ "$_is_exit_code" = true ]; then + if bashunit::doubles::is_exit_code "$exit_code_or_impl"; then body_suffix="return $exit_code_or_impl" elif [ -n "$exit_code_or_impl" ]; then body_suffix="$exit_code_or_impl \"\$@\"" diff --git a/tests/functional/doubles_test.sh b/tests/functional/doubles_test.sh index b6f61f38..ee374354 100644 --- a/tests/functional/doubles_test.sh +++ b/tests/functional/doubles_test.sh @@ -255,6 +255,45 @@ function test_passing_call_assertions_print_nothing() { assert_empty "$(assert_have_been_called_with spy_with_a_passing_assertion "first")" } +function test_mock_with_an_exit_code_returns_it_without_output() { + bashunit::mock mock_failing_command 1 + + local code=0 + local output + output="$(mock_failing_command 2>&1)" || code=$? + + assert_same "1" "$code" + assert_empty "$output" +} + +function test_mock_with_the_exit_code_zero_returns_zero() { + bashunit::mock mock_succeeding_command 0 + + local code=1 + mock_succeeding_command && code=0 + + assert_same "0" "$code" +} + +function test_mock_keeps_the_replacement_implementation_form() { + bashunit::mock mock_with_impl echo hello + + assert_same "hello" "$(mock_with_impl)" +} + +function test_mock_reads_only_a_lone_all_digits_argument_as_an_exit_code() { + # `echo 1` is an implementation whose first word happens to be a command, so + # the digits must stay an argument to it rather than becoming `return 1`. + bashunit::mock mock_with_numeric_argument echo 1 + + local code=1 + local output + output="$(mock_with_numeric_argument)" && code=0 + + assert_same "1" "$output" + assert_same "0" "$code" +} + function test_spy_on_echo_does_not_hang() { source ./tests/functional/fixtures/echo_function.sh bashunit::spy echo