Skip to content

Commit

Permalink
Minor release 0.7.0
Browse files Browse the repository at this point in the history
  • Loading branch information
razziel89 committed Mar 20, 2024
1 parent e94bc7c commit f0b3f98
Show file tree
Hide file tree
Showing 6 changed files with 363 additions and 28 deletions.
62 changes: 52 additions & 10 deletions bin/mock_exe.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
75 changes: 72 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand All @@ -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

Expand All @@ -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.

<!-- shellmock-helptext-end -->

Expand All @@ -111,7 +115,7 @@ from that point forward, assuming no code changes `PATH`.
<!-- shellmock-helptext-start -->

Syntax:
`shellmock config <name> <exit_code> [hook:<hook-function>] [1:<argspec> [...]]`
`shellmock config <name> [<exit_code>|forward] [hook:<hook-function>] [1:<argspec> [...]]`

The `config` command defines expectations for calls to your mocked executable.
You need to define expectations before you can make assertions on your mock.
Expand All @@ -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.
Expand Down Expand Up @@ -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

<!-- shellmock-helptext-start -->
Expand Down Expand Up @@ -707,3 +750,29 @@ the output would be as follows instead:
]
```
<!-- prettier-ignore-end -->

### delete

<!-- shellmock-helptext-start -->

Syntax:
`shellmock delete <name>`

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.

<!-- shellmock-helptext-end -->

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.
55 changes: 50 additions & 5 deletions lib/mock_management.bash
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,59 @@ __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

__shellmock_write_mock_exe > "${__SHELLMOCK_MOCKBIN}/${cmd}"
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=("$@")

Expand Down Expand Up @@ -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
Expand All @@ -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 < <(
Expand All @@ -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
Expand Down Expand Up @@ -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}]}"
Expand Down
31 changes: 22 additions & 9 deletions lib/shellmock.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading

0 comments on commit f0b3f98

Please sign in to comment.