From f0b3f9801b494a49f555f0d382d37327d7f79fc6 Mon Sep 17 00:00:00 2001 From: Torsten Long Date: Wed, 20 Mar 2024 10:25:01 +0100 Subject: [PATCH] Minor release 0.7.0 --- bin/mock_exe.sh | 62 ++++++++++++++++--- docs/usage.md | 75 ++++++++++++++++++++++- lib/mock_management.bash | 55 +++++++++++++++-- lib/shellmock.bash | 31 +++++++--- lib/store.bash | 39 ++++++++++++ tests/main.bats | 129 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 363 insertions(+), 28 deletions(-) create mode 100644 lib/store.bash diff --git a/bin/mock_exe.sh b/bin/mock_exe.sh index 8754ecf..1f0e63d 100755 --- a/bin/mock_exe.sh +++ b/bin/mock_exe.sh @@ -30,10 +30,13 @@ set -euo pipefail # Check whether required environment variables are set. env_var_check() { - if ! [[ -d ${__SHELLMOCK_OUTPUT-} ]]; then - echo "Vairable __SHELLMOCK_OUTPUT not defined or no directory." - exit 1 - fi + local var + for var in __SHELLMOCK_MOCKBIN __SHELLMOCK_FUNCSTORE __SHELLMOCK_OUTPUT; do + if ! [[ -d ${!var-} ]]; then + echo >&2 "Vairable ${var} not defined or no directory." + exit 1 + fi + done } get_and_ensure_outdir() { @@ -137,7 +140,7 @@ _match_spec() { errecho "Internal error, incorrect spec ${spec}" return 1 fi - done < <(base64 --decode <<< "${full_spec}") && wait $! + done < <(base64 --decode <<< "${full_spec}") && wait $! || return 1 } # Check whether the given process is a bats process. A bats process is a bash @@ -196,8 +199,10 @@ find_matching_argspec() { fi done < <( env | sed 's/=.*$//' \ - | grep -x "MOCK_ARGSPEC_BASE64_${cmd_b32}_[0-9][0-9]*" | sort -u - ) && wait $! + | { + grep -x "MOCK_ARGSPEC_BASE64_${cmd_b32}_[0-9][0-9]*" || : + } | sort -u + ) && wait $! || return 1 errecho "SHELLMOCK: unexpected call '${cmd} $*'" _kill_parent "${PPID}" @@ -248,7 +253,40 @@ return_with_code() { return 0 } +# Check whether this mock sould actually call the external executable instead of +# providing mock output and exit code. If it should forward, the value of the +# checked env var for this cmd_spec should be "forward". +should_forward() { + local cmd_spec="$1" + local rc_env_var + rc_env_var="MOCK_RC_${cmd_spec}" + [[ -n ${!rc_env_var-} && ${!rc_env_var} == forward ]] +} + +# Forward the arguments to the first executable in PATH that is not controlled +# by shellmock, that is the first executable not in __SHELLMOCK_MOCKBIN. We can +# also forward to functions that we stored, but those functions cannot access +# shell variables of the surrounding shell. +forward() { + local cmd=$1 + shift + local args=("$@") + + while read -r -d: path; do + if + [[ ${path} != "${__SHELLMOCK_MOCKBIN}" ]] \ + && PATH="${path}" command -v "${cmd}" &> /dev/null + then + local exe="${path}/${cmd}" + echo >&2 "SHELLMOCK: forwarding call: ${exe} $*" + exec "${exe}" "${args[@]}" + fi + done <<< "${__SHELLMOCK_FUNCSTORE}:${PATH}" +} + main() { + # Make sure that shell aliases never interfere with this mock. + unalias -a env_var_check # Determine our name. This assumes that the first value in argv is the name of # the command. This is almost always so. @@ -265,9 +303,13 @@ main() { # it cannot be found, either exit with an error or kill the parent process. local cmd_spec cmd_spec="$(find_matching_argspec "${outdir}" "${cmd}" "${cmd_b32}" "$@")" - provide_output "${cmd_spec}" - run_hook "${cmd_spec}" - return_with_code "${cmd_spec}" + if should_forward "${cmd_spec}"; then + forward "${cmd}" "$@" + else + provide_output "${cmd_spec}" + run_hook "${cmd_spec}" + return_with_code "${cmd_spec}" + fi } # Run if executed directly. If sourced from a bash shell, don't do anything, diff --git a/docs/usage.md b/docs/usage.md index a7593c1..1370def 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -45,7 +45,7 @@ sure to replace `${PATH_TO_SHELLMOCK_LIBRARY}` appropriately. You can access all functionality of Shellmock via the `shellmock` command. It is implemented as a shell function with the following sub-commands: -- `new`: +- `new` / `mock`: Create a new mock for an executable. - `config`: Configure a previously-created mock by defining expectations. @@ -55,6 +55,8 @@ It is implemented as a shell function with the following sub-commands: Configure global behaviour of Shellmock itself. - `calls`: Log past calls to mocks and suggest mock configs to reproduce. +- `delete` / `unmock`: + Remove all mocks for an executable. - `help`: Provide a help text. @@ -80,6 +82,7 @@ You can jump to the respective section via the following links. - [ensure-assertions](#ensure-assertions) - [calls](#calls) - [Example](#example) +- [delete](#delete) ### new @@ -91,6 +94,7 @@ Syntax: The `new` command creates a new mock executable called `name`. It is created in a directory in your `PATH` that is controlled by Shellmock. You need to create a mock before you can configure it or make assertions on it. +The `mock` command is an alias for the `new` command. @@ -111,7 +115,7 @@ from that point forward, assuming no code changes `PATH`. Syntax: -`shellmock config   [hook:] [1: [...]]` +`shellmock config  [|forward] [hook:] [1: [...]]` The `config` command defines expectations for calls to your mocked executable. You need to define expectations before you can make assertions on your mock. @@ -125,7 +129,8 @@ The `config` command takes at least two arguments: 1. the `name` of the mock you wish you define expectations for, and 2. the mock's `exit_code` for invocations matching the expectations configured - with this call. + with this call or the literal string `forward`. + See [below](#forwarding-calls) for details on forwarding calls. Next, you may optionally specify the name of a `bash` function that the mock will execute immediately before exiting. @@ -425,6 +430,44 @@ EOF shellmock config git 0 1:tag 2:--list <<< $'first\nsecond\n' ``` +### Forwarding Calls + +It can be desirable to mock only some calls to an executable. +For example, you may want to mock only `POST` request sent via `curl` but `GET` +requests should still be issued. +Or you may want to mock all calls to `git push` while other commands should +still be executed. + +You can forward specific calls to an executable by specifying the literal string +`forward` as the second argument to the `config` command. +Calls matching argspecs provided this way will be forwarded to the actual +executable. + +**Example**: + +```bash +# Initialising mock for curl. +shellmock new curl +# Mocking all POST requests, i.e. calls that have the literal string POST as +# argument anywhere. +shellmock config curl 0 any:POST <<< "my mock output" +# Forwarding all GET requests, i.e. calls that have the literal string GET as +# argument anywhere. +shellmock config curl forward any:GET +``` + +**Example**: + +```bash +# Initialising mock for git. +shellmock new git +# Mocking all push commands, i.e. calls that have the literal string push as +# first argument. +shellmock config git 0 1:push +# Forwarding all other calls. Specific configurations have to go first. +shellmock config git forward +``` + ### assert @@ -707,3 +750,29 @@ the output would be as follows instead: ] ``` + +### delete + + + +Syntax: +`shellmock delete ` + +The `delete` command completely removes all mocks for `name`. +Mock executables are removed from your `PATH` and environment variables used to +configure the mock are removed. +The `unmock` command is an alias for the `delete` command. + + + +The `delete` command takes exactly one argument: +the name of the executable whose mocks shall be removed. +For example: + +```bash +shellmock delete git +``` + +This will remove the mock executable for `git`. +It will also undo all mock configurations issued via `shellmock config git`. +After unmocking, new mocks can be created for the very same executable. diff --git a/lib/mock_management.bash b/lib/mock_management.bash index 4924fd4..b8e5962 100644 --- a/lib/mock_management.bash +++ b/lib/mock_management.bash @@ -26,7 +26,10 @@ __shellmock__new() { if [[ $(type -t "${cmd}") == function ]]; then # We are mocking a function, unset it or it will take precedence over our - # injected executable. + # injected executable. However, store the original function so that we could + # restore it. + __shellmock_internal_funcstore "${cmd}" > "${__SHELLMOCK_FUNCSTORE}/${cmd}" + chmod +x "${__SHELLMOCK_FUNCSTORE}/${cmd}" unset -f "${cmd}" fi @@ -34,6 +37,48 @@ __shellmock__new() { chmod +x "${__SHELLMOCK_MOCKBIN}/${cmd}" } +# An alias for the "new" command. +__shellmock__mock() { + __shellmock__new "$@" +} + +__shellmock__unmock() { + __shellmock_internal_pathcheck + __shellmock_internal_trapcheck + + local cmd="$1" + local cmd_b32 + cmd_b32=$(base32 -w0 <<< "${cmd}" | tr "=" "_") + + # Restore the function if we are mocking one. + local store="${__SHELLMOCK_FUNCSTORE}/${cmd}" + if [[ -f ${store} ]]; then + # shellcheck disable=SC1090 + source "${store}" + rm "${store}" + fi + + # In any case, remove the mock and unset all env vars defined for it. Mocks + # are identified by their argspecs or return codes. Thus, we only remove those + # env vars. + local env_var + while read -r env_var; do + unset "${env_var}" + done < <( + env | sed 's/=.*$//' \ + | { grep -xE "^MOCK_(RC|ARGSPEC_BASE64)_${cmd_b32}_[0-9][0-9]*" || :; } + ) && wait $! || return 1 + + if [[ -f "${__SHELLMOCK_MOCKBIN}/${cmd}" ]]; then + rm "${__SHELLMOCK_MOCKBIN}/${cmd}" + fi +} + +# An alias for the "unmock" command. +__shellmock__delete() { + __shellmock__unmock "$@" +} + __shellmock_assert_no_duplicate_argspecs() { local args=("$@") @@ -209,7 +254,7 @@ __shellmock__assert() { fi done < <( find "${__SHELLMOCK_OUTPUT}/${cmd_b32}" -mindepth 2 -type f -name stderr - ) && wait $! + ) && wait $! || return 1 if [[ ${has_err} -ne 0 ]]; then echo >&2 "SHELLMOCK: got at least one unexpected call for mock ${cmd}." return 1 @@ -226,7 +271,7 @@ __shellmock__assert() { find "${__SHELLMOCK_OUTPUT}/${cmd_b32}" -mindepth 2 -type f \ -name argspec -print0 | xargs -r -0 cat | sort -u fi - ) && wait $! + ) && wait $! || return 1 declare -a expected_argspecs mapfile -t expected_argspecs < <( @@ -235,7 +280,7 @@ __shellmock__assert() { env | sed 's/=.*$//' \ | { grep -x "MOCK_ARGSPEC_BASE64_${cmd_b32}_[0-9][0-9]*" || :; } \ | sort -u - ) && wait $! + ) && wait $! || return 1 local has_err=0 for argspec in "${expected_argspecs[@]}"; do @@ -317,7 +362,7 @@ __shellmock__calls() { readarray -d $'\n' -t call_ids < <( find "${__SHELLMOCK_OUTPUT}/${cmd_b32}" -mindepth 1 -maxdepth 1 -type d \ | sort -n - ) && wait $! + ) && wait $! || return 1 for call_idx in "${!call_ids[@]}"; do local call_id="${call_ids[${call_idx}]}" diff --git a/lib/shellmock.bash b/lib/shellmock.bash index 96fb1d3..44e0956 100644 --- a/lib/shellmock.bash +++ b/lib/shellmock.bash @@ -16,6 +16,17 @@ # License for the specific language governing permissions and limitations under # the License. +__shellmock_mktemp() { + local has_bats=$1 + local what=$2 + local dir + dir=$(mktemp -d -p "${BATS_TEST_TMPDIR-${TMPDIR-/tmp}}") + if [[ ${has_bats} -eq 0 ]]; then + echo >&2 "Keeping ${what} in: ${dir}" + fi + echo "${dir}" +} + # Initialise shellmock, which includes setting up temporary directories either # as subdirectories of bats' temporary ones when run via bats, or global # temporary directories when run without bats. This function also modifies PATH @@ -31,25 +42,27 @@ __shellmock_internal_init() { if [[ -z ${BATS_TEST_TMPDIR} ]]; then has_bats=0 fi + + if [[ ${has_bats} -eq 0 ]]; then + echo >&2 "Running outside of bats, temporary directories will be kept." + fi + # Modify PATH to permit injecting executables. declare -gx __SHELLMOCK_MOCKBIN - __SHELLMOCK_MOCKBIN="$(mktemp -d -p "${BATS_TEST_TMPDIR-${TMPDIR-/tmp}}")" + __SHELLMOCK_MOCKBIN="$(__shellmock_mktemp "${has_bats}" "mocks")" export PATH="${__SHELLMOCK_MOCKBIN}:${PATH}" declare -gx __SHELLMOCK_OUTPUT - __SHELLMOCK_OUTPUT="$(mktemp -d -p "${BATS_TEST_TMPDIR-${TMPDIR-/tmp}}")" + __SHELLMOCK_OUTPUT="$(__shellmock_mktemp "${has_bats}" "mock call data")" + + declare -gx __SHELLMOCK_FUNCSTORE + __SHELLMOCK_FUNCSTORE="$(__shellmock_mktemp "${has_bats}" "mocked functions")" declare -gx __SHELLMOCK_EXPECTATIONS_DIR __SHELLMOCK_EXPECTATIONS_DIR="$( - mktemp -d -p "${BATS_TEST_TMPDIR-${TMPDIR-/tmp}}" + __shellmock_mktemp "${has_bats}" "call records" )" - if [[ ${has_bats} -eq 0 ]]; then - echo >&2 "Running outside of bats, temporary directories will be kept." - echo >&2 "Keeping mocks in: ${__SHELLMOCK_MOCKBIN}" - echo >&2 "Keeping mock call data in: ${__SHELLMOCK_OUTPUT}" - fi - declare -gx __SHELLMOCK_PATH # Remember the value of "${PATH}" when shellmock was loaded, including the # prepended mockbin dir. diff --git a/lib/store.bash b/lib/store.bash new file mode 100644 index 0000000..397725c --- /dev/null +++ b/lib/store.bash @@ -0,0 +1,39 @@ +#!/bin/bash + +# Copyright (c) 2022 - for information on the respective copyright owner +# see the NOTICE file or the repository +# https://github.com/boschresearch/shellmock +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# Output a function as a reusable block of code. The generated script can be +# sourced to define the function. The generated script can also be executed to +# call the function. Arguments will be forwarded to the function. +__shellmock_internal_funcstore() { + local cmd="$1" + + # Make sure the generated script will always be called with the same shell we + # are using at the moment. + printf "#!%s\n# " "${BASH}" + type "${cmd}" + cat << 'EOF' +# Run only if executed directly. +if [[ -z ${BASH_SOURCE[0]-} ]] || [[ ${BASH_SOURCE[0]} == "${0}" ]]; then +EOF + printf '%s "$@"\n' "${cmd}" + cat << 'EOF' +else + : +fi +EOF +} diff --git a/tests/main.bats b/tests/main.bats index 4bf6b67..5abfb6e 100644 --- a/tests/main.bats +++ b/tests/main.bats @@ -360,7 +360,6 @@ setup() { # Using "i" as location increases previous counter by 1, starting at 1 for the # first "i". But this one fails because "any" is not a suitable numeric base # for incrementing. - local stderr if stderr="$( shellmock config my_exe 0 any:muhaha i:asdf i:blub 2>&1 1> /dev/null )"; then @@ -532,3 +531,131 @@ indices, cannot continue: 1 2 " shellmock new my_exe run ! shellmock config my_exe 0 hook:_missing_hook } + +@test "removing and re-creating a mock after creating one" { + # Not defined at first. + run ! my_exe + + # Mocked and can be called. + shellmock new my_exe + shellmock config my_exe 1 + run -1 my_exe + shellmock assert expectations my_exe + + # Restore original state by deleting the mock config. The exe will no longer + # defined. + shellmock unmock my_exe + run ! my_exe + + # Mocked again and can be called. + shellmock new my_exe + shellmock config my_exe 0 1:arg1 + shellmock config my_exe 0 1:arg2 + run -0 my_exe arg1 + run -0 my_exe arg2 + shellmock assert expectations my_exe +} + +@test "removing and re-creating a function mock after creating one" { + _my_fn() { + echo >> "${BATS_TEST_TMPDIR}/called" + return 2 + } + _my_fn_assert_called() { + local expected=$1 + local actual + actual=$(wc -l < "${BATS_TEST_TMPDIR}/called") + [[ ${actual} -eq ${expected} ]] + } + + run -2 _my_fn + _my_fn_assert_called 1 + + # Mocked and can be called. + shellmock new _my_fn + shellmock config _my_fn 1 + run -1 _my_fn + shellmock assert expectations _my_fn + _my_fn_assert_called 1 + + # Restore original state, no longer defined. Restoring doesn't call it. + shellmock unmock _my_fn + _my_fn_assert_called 1 + run -2 _my_fn + _my_fn_assert_called 2 + [[ "$(type -t _my_fn)" == function ]] + + # Mocked again and can be called. + shellmock new _my_fn + shellmock config _my_fn 0 1:arg1 + shellmock config _my_fn 0 1:arg2 + run -0 _my_fn arg1 + run -0 _my_fn arg2 + shellmock assert expectations _my_fn + _my_fn_assert_called 2 +} + +@test "forwarding some calls to actual executable" { + ls=$(command -v ls) + shellmock new ls + mkdir -p "${BATS_TEST_TMPDIR}/dir" + touch "${BATS_TEST_TMPDIR}/dir/file" + shellmock config ls forward 1:"${BATS_TEST_TMPDIR}/dir" + + # Making the linter happy. + stderr= + # Calling actual executable. + run --separate-stderr -0 ls "${BATS_TEST_TMPDIR}/dir" + [[ ${output} == "file" ]] + shellmock assert expectations ls + [[ ${stderr} == *"SHELLMOCK: forwarding call: ${ls} ${BATS_TEST_TMPDIR}"* ]] +} + +@test "mocking only some calls to an executable" { + shellmock new ls + # Configuring forwarding mock first because configs are checked in order. + shellmock config ls forward 1:--help + # Setting up catch-all mock. + shellmock config ls 2 + + # Calling actual executable. + run -0 ls --help + [[ -n ${output} ]] + # Mock has not yet been called. + run ! shellmock assert expectations ls + # Calling the mock. + run -2 ls + shellmock assert expectations ls +} + +@test "forwarding some calls to a function" { + _forward_fn() { + echo "$*" > "${BATS_TEST_TMPDIR}/args" + echo >> "${BATS_TEST_TMPDIR}/called" + return 2 + } + _forward_fn_assert_called() { + local expected=$1 + local actual + actual=$(wc -l < "${BATS_TEST_TMPDIR}/called") + [[ ${actual} -eq ${expected} ]] + } + + run -2 _forward_fn + _forward_fn_assert_called 1 + + shellmock new _forward_fn + shellmock config _forward_fn forward 1:arg + shellmock config _forward_fn 0 + + # Calling mock. + run -0 _forward_fn + _forward_fn_assert_called 1 + # Calling actual function. + run -2 _forward_fn arg another_arg + _forward_fn_assert_called 2 + + shellmock assert expectations _forward_fn + # Check that the function receives arguments. + [[ $(cat "${BATS_TEST_TMPDIR}/args") == "arg another_arg" ]] +}