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