diff --git a/.github/workflows/test-lint-deploy.yml b/.github/workflows/test-lint-deploy.yml new file mode 100644 index 0000000..2b3e95c --- /dev/null +++ b/.github/workflows/test-lint-deploy.yml @@ -0,0 +1,87 @@ +# Copyright (c) 2022 Robert Bosch GmbH and its subsidiaries. +# +# 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. + +name: test, lint & deploy + +on: + push: + pull_request: + # Set workflow_dispatch to enable triggering the workflow in the web UI. + workflow_dispatch: + +defaults: + run: + shell: bash + +concurrency: + group: ${{ github.workflow }}:${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + build-deploy: + timeout-minutes: 8 + + runs-on: ubuntu-22.04 + + steps: + - name: Install system dependencies + env: + DEBIAN_FRONTEND: noninteractive + run: | + sudo apt-get update -qq + sudo apt-get install -qqy --no-install-recommends \ + ca-certificates coreutils curl git jq kcov make shellcheck + + - name: Install bats + run: | + git clone https://github.com/bats-core/bats-core.git + sudo ./bats-core/install.sh /usr/local + rm -rf bats-core + + - name: Disable git config safety checks for this repository + run: | + git config --global --add safe.directory "$(pwd)" + + - name: Install shfmt + env: + VERSION: 3.7.0 + SHA256: 0264c424278b18e22453fe523ec01a19805ce3b8ebf18eaf3aadc1edc23f42e3 + URL: https://github.com/mvdan/sh/releases/download + run: | + curl -o shfmt --location \ + "${URL}/v${VERSION}/shfmt_v${VERSION}_linux_amd64" + echo "${SHA256} shfmt" | sha256sum -c + chmod +x shfmt + sudo mv shfmt /usr/local/bin/shfmt + + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Test Package + run: make test + + - name: Lint Package + run: make lint + + - name: Build Package + run: make build + + - name: Publish package on GH (only tags) + if: ${{ startsWith(github.ref, 'refs/tags/') }} + uses: softprops/action-gh-release@v1 + with: + files: shellmock.bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38d4107 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Deployable. +/shellmock.bash +/.coverage diff --git a/3rd-party-licenses.md b/3rd-party-licenses.md new file mode 100644 index 0000000..f9ecdb4 --- /dev/null +++ b/3rd-party-licenses.md @@ -0,0 +1,21 @@ + + +# Third-party Licences + +Shellmock does not include any third-party software. diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..e888159 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,2 @@ +# Default code owners: +* @razziel89 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d23d8e2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,161 @@ + + +# Contributing + +Want to contribute? +Great! +You can do so through the standard GitHub pull request model. +For large contributions we do encourage you to file a ticket in the GitHub +issues tracking system prior to any code development to coordinate with the +Shellmock team early in the process. +Coordinating up front helps to avoid frustration later on. +Please make sure to: + + - add tests for all new or updated code + - make sure existing tests work by running `make test` + - make sure the code follows this repository's guidelines by running the + commands `make format` and `make lint` + +Your contribution must be licensed under the Apache-2.0 license, the license +used by this project. + +## Add / retain copyright notices + +Include a copyright notice and license in each new file to be contributed, +consistent with the style used by this project. +If your contribution contains code under the copyright of a third party, +document its origin, license, and copyright holders. + +## Sign your work + +This project tracks patch provenance and licensing using the Developer +Certificate of Origin 1.1 (DCO) from [developercertificate.org][DCO] and +Signed-off-by tags initially developed by the Linux kernel project. + +```text +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. +1 Letterman Drive +Suite D4700 +San Francisco, CA, 94129 + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. +``` + +With the sign-off in a commit message you certify that you authored the patch or +otherwise have the right to submit it under an open source license. +The procedure is simple: To certify above Developer's Certificate of Origin 1.1 +for your contribution just append a line + +```text +Signed-off-by: Random J Developer +``` + +to every commit message using your real name or your pseudonym and a valid email +address. + +If you have set your `user.name` and `user.email` git config entries, you can +automatically sign the commit by running the git-commit command with the `-s` +option. +There may be multiple sign-offs if more than one developer was involved in +authoring the contribution. + +For a more detailed description of this procedure, please see +[SubmittingPatches], which was extracted from the Linux kernel project, and +which is stored in an external repository. + +### Individual vs. Corporate Contributors + +Often employers or academic institution have ownership over code that is +written in certain circumstances, so please do due diligence to ensure that +you have the rights to submit the code. + +If you are a developer who is authorized to contribute to Shellmock on behalf of +your employer, then please use your corporate email address in the Signed-off-by +tag. +Otherwise please use a personal email address. + +## Maintain Copyright holder / Contributor list + +Each contributor is responsible for identifying themselves in the +[NOTICE](./NOTICE) file, the project's list of copyright holders and authors. +Please add the respective information corresponding to the Signed-off-by tag as +part of your first pull request. + +If you are a developer who is authorized to contribute to Shellmock on behalf of +your employer, then add your company / organization to the list of copyright +holders in the [NOTICE](./NOTICE) file. +As author of a corporate contribution you can also add your name and corporate +email address as in the Signed-off-by tag. + +If your contribution is covered by this project's DCO's clause "(c) The +contribution was provided directly to me by some other person who certified (a) +or (b) and I have not modified it", please add the appropriate copyright +holder(s) to the [NOTICE](./NOTICE) file as part of your contribution. + +[DCO]: https://developercertificate.org/ +[SubmittingPatches]: https://github.com/wking/signed-off-by/blob/7d71be37194df05c349157a2161c7534feaf86a4/Documentation/SubmittingPatches + + diff --git a/LICENSE b/LICENSE index 261eeb9..d645695 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..72d401d --- /dev/null +++ b/Makefile @@ -0,0 +1,88 @@ +# 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. + +SHELL := /bin/bash -euo pipefail + +default: build + +.PHONY: check-dependencies +check-dependencies: + command -v bash &>/dev/null || (echo "ERROR, please install bash" >&2; exit 1) + command -v bats &>/dev/null || (echo "ERROR, please install bats" >&2; exit 1) + command -v find &>/dev/null || (echo "ERROR, please install find" >&2; exit 1) + command -v shellcheck &>/dev/null || (echo "ERROR, please install shellcheck" >&2; exit 1) + command -v shfmt &>/dev/null || (echo "ERROR, please install shfmt" >&2; exit 1) + command -v jq &>/dev/null || (echo "ERROR, please install jq" >&2; exit 1) + command -v kcov &>/dev/null || (echo "ERROR, please install kcov " >&2; exit 1) + +SHELLCHECK_OPTS := --enable=add-default-case,avoid-nullary-conditions,quote-safe-variables,require-variable-braces +export SHELLCHECK_OPTS + +.PHONY: lint +lint: + shellcheck ./bin/* ./lib/* ./tests/* + $(MAKE) check-format + +.PHONY: test +test: build + bats --print-output-on-failure ./tests/*.bats + +COVERAGE_FAILED_MESSAGE := \ + Cannot generate coverage reports as root user because kcov is not \ + compatible with current versions of bash when run as root, also see \ + https://github.com/SimonKagstrom/kcov/issues/234\#issuecomment-453929674 + +coverage: test + if [[ "$$(id -ru)" -eq 0 ]]; then \ + echo >&2 "$(COVERAGE_FAILED_MESSAGE)"; exit 1; \ + fi + # Generate coverage reports. + kcov --bash-dont-parse-binary-dir --clean --include-path=. ./.coverage \ + bats --print-output-on-failure ./tests/*.bats + # Analyse output of coverage reports and fail if not all files have been + # covered of if coverage is not high enough. + awk \ + -v min_cov="92" \ + -v tot_num_files="1" \ + 'BEGIN{num_files=0; cov=0;} \ + $$1 ~ /"file":/{num_files++} \ + $$1 ~ /"covered_lines":/{cov=$$2} \ + $$1 ~ /"total_lines":/{tot_cov=$$2} \ + END{ \ + if(num_files!=tot_num_files){ \ + printf("Not all files covered: %d < %d\n", num_files, tot_num_files); exit 1 \ + } \ + } \ + END{ \ + if(cov/tot_cov < min_cov/100){ \ + printf("Coverage too low: %.2f < %.2f\n", cov/tot_cov, min_cov/100); exit 1 \ + } \ + }' < <(jq < $$(ls -d1 .coverage/bats.*/coverage.json) | sed 's/,$$//') + +format: + shfmt -w -bn -i 2 -sr -ln bash ./bin/* ./lib/* + shfmt -w -bn -i 2 -sr -ln bats ./tests/* + +check-format: + shfmt -d -bn -i 2 -sr -ln bash ./bin/* ./lib/* + shfmt -d -bn -i 2 -sr -ln bats ./tests/* + +build: + ./generate_deployable.sh + +clean: + rm -f shellmock.bash + rm -fr .coverage diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..56718a2 --- /dev/null +++ b/NOTICE @@ -0,0 +1,28 @@ +# This is the official list of Shellmock copyright holders and authors. +# +# Often employers or academic institutions have ownership over code that is +# written in certain circumstances, so please do due diligence to ensure that +# you have the right to submit the code. +# +# When adding J Random Contributor's name to this file, either J's name on its +# own or J's name associated with J's organization's name should be added, +# depending on whether J's employer (or academic institution) has ownership +# over code that is written for this project. +# +# How to add names to this file: +# Individual's name . +# +# If Individual's organization is copyright holder of her contributions add the +# organization's name, optionally also the contributor's name: +# +# Organization's name +# Individual's name +# +# Please keep the list sorted. + +Robert Bosch GmbH + Hana Boukricha + Hannes Becker + Johannes Döllinger @realJohnDoe + Océane Rumfels + Torsten Long @razziel89 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c88e84 --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ + + +# shellmock + +- [Quickstart Guide](#quickstart-guide) +- [Further examples](#further-examples) +- [Feedback](#feedback) +- [About](#about) + - [Maintainers](#maintainers) + - [Contributors](#contributors) + - [3rd Party Licenses](#3rd-party-licenses) + - [Used Encryption](#used-encryption) + - [License](#license) + +This is the `shellmock` project, a mocking framework for shell scripts. +It works well with the [bats-core] testing framework. +You can find our technical documentation [here](./docs/README.md). +Inspiration for `shellmock` was taken from testing frameworks such as `pytest` +and `golang/mock`. + +[bats-core]: https://bats-core.readthedocs.io/ "bats core website" + +## Quickstart Guide + +If you simply want to get this show in the road, just head over to the +[releases](./releases), download the [latest release](./releases/latest). +Then, you can write your [bats-core]-based tests. +Just make sure to use `load shellmock` in your `setup` function. +You can also have a look at [this example](./docs/example.md) or +[shellmock's own tests][shellmock-tests]. + +The [technical documentation](./docs/README.md) will go more in depth on how to +use `shellmock` to mock any commands but here is a simple example of the command +and what each parameter does. + +```bash +# Source the self-contained shellmock library, which makes the shellmock command +# available. This is replaced by "load shellmock" in bats-based tests. +. shellmock.bash +# Instantiate a new mock for the curl command. +shellmock new curl +# Configure the mock. +shellmock config curl 0 1:http://www.google.com +# Calling the curl command. The mock will be called instead. +curl http://www.google.com +``` + +The `shellmock` command: + +- `shellmock new curl` + + Create a mock executable called `curl` in a directory `shellmock` controls. + Then, `shellmock` will modify the `PATH` environment variable to make + sure that the mock it controls is used preferentially to the actual + `curl` executable on your system. + +- `shellmock config curl 0 1:http://www.google.com` + + Configure the mock. + Here, you specify the arguments you expect your command + to be called with, as well as the mock's exit status code. + + - `0`: the exit status code of the mock (`0` means "success") + - `1:http://www.google.com`: State that the first argument of the command + is expected to be the literal string `http://www.google.com`. + Note that counting arguments starts at 1. + +Please have a look at the [full command reference](./docs/usage.md) for all +details. + +[shellmock-tests]: ./tests/main.bats "shellmock tests" + +### Dependencies + +The following tools are needed to use `shelmock`: + +- `awk` +- `base64` +- `bash` +- `cat` +- `env` +- `find` +- `grep` +- `sed` +- `sort` +- `xargs` + +On Debian-based systems, they can be installed via: + +```bash +sudo apt install -yqq coreutils findutils gawk grep sed +``` + +## Further examples + +As mentioned before, you can check out more examples in [shellmock's own +tests][shellmock-tests]. +A non-exhaustive list of examples follows: + +- Mock an executable +- Mock functions +- Mock with non-zero exit code +- Match positional arguments, both with fixed and flexible positions +- Create a mock that is writing a fixed string to stdout +- Kill the parent process when there is an unexpected call to fail a test + +## Feedback + +Like what we did? +Great, we’d love to hear that. +Don’t like it? +Not so great! +But we are eager to hear your feedback on how we could improve! + +## About + +### Maintainers + +['Torsten Long'](https://github.com/razziel89) + +### License + +Shellmock is open-sourced under the Apache-2.0 license. +See the [LICENSE](./LICENSE) file for details. + +> 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. + + diff --git a/bin/mock_exe.sh b/bin/mock_exe.sh new file mode 100755 index 0000000..f033a95 --- /dev/null +++ b/bin/mock_exe.sh @@ -0,0 +1,224 @@ +#!/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. + +# This file contains the mock executable used by shellmock. As such, it can +# impersonate any executable just by being called with a specific name. It also +# uses the environment variables set by the main shellmock library to determine +# which configured call to match and what to write to stdout, if anything. +# +# This script will write its arguments and stdin to files in a sub-directory of +# __SHELLMOCK_OUTPUT. No two calls will overwrite each other's data. The data +# stored this way can be used by the main shellmock library to assert +# user-defined expectations. + +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 +} + +get_and_ensure_outdir() { + local cmd="$1" + # Ensure no two calls overwrite each other in a thread-safe way. + local count=0 + local outdir="${__SHELLMOCK_OUTPUT}/${cmd}/${count}" + while ! ( + # Increment the counter until we find one that has not been used before. + flock -n 9 || exit 1 + [[ -d ${outdir} ]] && exit 1 + mkdir -p "${outdir}" + ) 9> "${__SHELLMOCK_OUTPUT}/lockfile_${cmd}_${count}"; do + count=$((count + 1)) + outdir="${__SHELLMOCK_OUTPUT}/${cmd}/${count}" + done + echo "${outdir}" +} + +# When called, this script will write its own errors to a file so that they can +# be retrieved later when asserting expectations. +errecho() { + echo >> "${STDERR}" "$@" +} + +output_args_and_stdin() { + local outdir="$1" + shift + + # Split arguments by newlines. This will cause problems if there are ever + # arguments with newlines, of course. Improvements are welcome. + for arg in "$@"; do + printf -- "%s\n" "${arg-}" + done > "${outdir}/args" + # If stdin is a terminal, we are called interactively. Don't output our stdin + # in this case. Only output our stdin if we are not invoked interactively. + # Otherwise, this would block until the user hit Ctrl+D to send EOF. + if ! [[ -t 0 ]]; then + cat - > "${outdir}/stdin" + fi +} + +_find_arg() { + local arg="$1" + shift + local args=("$@") + + for check in "${args[@]}"; do + if [[ ${arg} == "${check}" ]]; then + return 0 + fi + done + + return 1 +} + +_find_regex_arg() { + local regex="$1" + shift + local args=("$@") + + for check in "${args[@]}"; do + if [[ ${check} =~ ${regex} ]]; then + return 0 + fi + done + + return 1 +} + +# Determine whether an argspec defined via a specific environment variable +# matches the arguments this mock received. +_match_spec() { + local full_spec="$1" + shift + + while read -r spec; do + local id val + id="$(awk -F: '{print $1}' <<< "${spec}")" + val="${spec##"${id}":}" + + if [[ ${spec} =~ ^any: ]]; then + if ! _find_arg "${val}" "$@"; then + return 1 + fi + elif [[ ${spec} =~ ^[0-9][0-9]*: ]]; then + if [[ ${val} != "${!id-}" ]]; then + return 1 + fi + elif [[ ${spec} =~ ^regex-any: ]]; then + if ! _find_regex_arg "${val}" "$@"; then + return 1 + fi + elif [[ ${spec} =~ ^regex-[0-9][0-9]*: ]]; then + id="${id##regex-}" + if ! [[ ${!id-} =~ ${val} ]]; then + return 1 + fi + else + errecho "Internal error, incorrect spec ${spec}" + return 1 + fi + done < <(base64 --decode <<< "${full_spec}") +} + +_kill_parent() { + local parent="$1" + + if [[ ${__SHELLMOCK__KILLPARENT-} -ne 1 ]]; then + return + fi + errecho "Killing parent process with information:" + # In case the `ps` command fails (e.g. because we mock it), don't fail this + # mock. + errecho "$(ps -p "${parent}" -lF || :)" + kill "${parent}" +} + +find_matching_argspec() { + # Find arg specs for this command and determine whether a specification + # matches. + local env_var + while read -r env_var; do + + if _match_spec "${!env_var}" "$@"; then + echo "${env_var##MOCK_ARGSPEC_BASE64_}" + echo "${env_var}" > "${outdir}/argspec" + return 0 + fi + done < <( + env | sed 's/=.*$//' \ + | grep -x "MOCK_ARGSPEC_BASE64_${cmd}_[0-9][0-9]*" | sort -u + ) + + errecho "SHELLMOCK: unexpected call to '$0 $*'" + _kill_parent "${PPID}" + return 1 +} + +provide_output() { + local cmd_spec="$1" + # Base64 encoding is an easy way to be able to store arbitrary data in + # environment variables. + output_base64="MOCK_OUTPUT_BASE64_${cmd_spec}" + if [[ -n ${!output_base64} ]]; then + base64 --decode <<< "${!output_base64}" + fi +} + +return_with_code() { + local cmd_spec="$1" + # If a return code was specified, exit with that return code. Otherwise exit + # with success. + local rc_env_var + rc_env_var="MOCK_RC_${cmd_spec}" + if [[ -n ${!rc_env_var} ]]; then + return "${!rc_env_var}" + fi + return 0 +} + +main() { + 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. + local cmd + cmd="$(basename "$0")" + local outdir + outdir="$(get_and_ensure_outdir "${cmd}")" + declare -g STDERR="${outdir}/stderr" + # Stdin is consumed in the function output_args_and_stdin. + output_args_and_stdin "${outdir}" "$@" + # Find the matching argspec defined by the user. If found, write the + # associated information to stdout and exit with the associated exit code. If + # it cannot be found, either exit with an error or kill the parent process. + local cmd_spec + cmd_spec="$(find_matching_argspec "$@")" + provide_output "${cmd_spec}" + return_with_code "${cmd_spec}" +} + +# Run if executed directly. If sourced from a bash shell, don't do anything, +# which simplifies testing. +if [[ -z ${BASH_SOURCE[0]-} ]] || [[ ${BASH_SOURCE[0]} == "${0}" ]]; then + main "$@" +else + : +fi diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b3a3d31 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,28 @@ + + +# Shellmock - Technical Documentation + +This is the in-depth technical documentation of `shellmock`. +You can find more information about: + +- [Building the deployable](./build.md) +- [Usage and command reference](./usage.md) +- [Detailed Example](./example.md) + +You can also go back to [the main readme](../README.md). diff --git a/docs/build.md b/docs/build.md new file mode 100644 index 0000000..2c521b2 --- /dev/null +++ b/docs/build.md @@ -0,0 +1,23 @@ + + +# Build the Deployable + +Simply run the script `./generate_deployable.sh` at the top level of this +repository to generate `shellmoch.bash`. +It uses only the standard Unix tools `bash`, `cat`, and `awk` to generate it. diff --git a/docs/example.md b/docs/example.md new file mode 100644 index 0000000..fcfcba4 --- /dev/null +++ b/docs/example.md @@ -0,0 +1,145 @@ + + +# Detailed Example + +This example first presents a script that shall then be tested using [bats-core] +and `shellmock`. + +Assume a very simple shell script that checks out a `git` branch +indiscriminately. +If the branch does not yet exist, the script creates it first. +That script could look like this: + +```bash +#!/bin/bash +# Read argument to script. +branch_name="$1" +# Ensure the argument is non-empty. +if [[ -z "${branch_name}" ]]; then + echo "Empty argument received." >&2 + # This command always exits with an error. It's the last one executed and, + # thus, its exit code will be the one of this script. It is important not to + # call the "exit" command in scripts that should be easy to test. + false +else + # Check whether the branch exists. + if ! git rev-parse --quiet --verify "${branch_name}"; then + # Branch does not yet exist, create it. + git branch "${branch_name}" + fi + # Check out the branch. It is guaranteed to exist. + git checkout "${branch_name}" +fi +``` + +There are a few obvious tests you could perform. +For example, you could test: + +- the success case with a missing branch, +- the success case with an existing branch, or +- the failure case with empty input. + +The below examples assume some familiarity with [bats-core]. +If you want to get started with [bats-core]-based testing, we can recommend this +[bats testing guide][bats-guide]. +Although, instead of installing [bats-core] as a `git` sub-module, we recommend +a user-space installation via `npm` via `npm install -g bats`. + +Below, you can find the three example tests mentioned above. + +```bash +#!/usr/bin/env bats + +setup_file() { + # Ensure we use the minimum required bats version for the "run" built-in. + bats_require_minimum_version 1.5.0 +} + +setup() { + # Load the downloaded shellmock library. The ".bash" extension is added + # automatically. The path is interpreted relative to the file containing the + # tests. + load shellmock +} + +@test "the success case with an existing branch" { + # Shadow original git executable by a mock. + shellmock new git + # Configure the mock to have an exit code of 0 if it is called with the + # rev-parse command. This simulates git reporting that the branch exists. The + # name can be at any position. + shellmock config git 0 1:rev-parse any:some_branch + # Configure the mock to have an exit code of 0 if it is called with the + # checkout command and a specific branch name. + shellmock config git 0 1:checkout 2:some_branch + # Now run your script via the "run" built-in. Here "${script}" contains the + # path to your executable script. + run "${script}" some_branch + # Now assert that the calls you expected have indeed happened. If there had + # been an unexpected call, e.g. to " git branch", this line would error out + # and report the problem. + shellmock assert expectations git + # Assert on the exit code. + [[ ${status} -eq 0 ]] +} + +@test "the success case with a missing branch" { + shellmock new git + # Configure the mock to have an exit code of 1 if it is called with the + # rev-parse command for a feature branch. This simulates git reporting that + # the branch does not exist. We match an argument at any position with a bash + # regular expression. + shellmock config git 1 1:rev-parse regex-any:"^feature/.*$" + # Configure the mock to have an exit code of 0 if it is called with the + # branch command and a specific branch name. We match the branch name, which + # is argument 2, with a bash regular expression. + shellmock config git 0 1:branch regex-2:"^feature/.*$" + # The checkout command should also succeed for any feature branch. + shellmock config git 0 1:checkout regex-2:"^feature/.*$" + run "${script}" "feature/some-feature" + shellmock assert expectations git + [[ ${status} -eq 0 ]] +} + +@test "the failure case with empty input" { + # Shadow the original git executable by a mock. This is just to make sure we + # do not call the actual git executable by accident. + shellmock new git + run "${script}" + shellmock assert expectations git + # Assert on the exit code. We expect a non-zero exit code. + [[ ${status} -ne 0 ]] +} +``` + +The above example calls the script itself via the `run` built-in. +For more complex scripts, you want to be able to test parts of it instead of the +whole script at once. +To do so, you need to use shell functions throughout and source the script in +your tests (also see [this guide][testability] and [this guide][testing]). +Doing so will allow you to test individual functions. +You can also mock functions called by your own functions. +Please have a look at [shellmock's own tests][shellmock-tests] for what is +possible. + +[bats-core]: https://bats-core.readthedocs.io/ "bats core website" +[bats-guide]: https://bats-core.readthedocs.io/en/stable/tutorial.html "bats guide" +[shellmock-tests]: https://github.boschdevcloud.com/bios-bcai/shellmock/blob/develop/tests/main.bats "shellmock tests" +[testability]: https://github.boschdevcloud.com/bios-bcai/shell-scripting-kickstarter/blob/main/testability.md "testability" +[testing]: https://github.boschdevcloud.com/bios-bcai/shell-scripting-kickstarter/blob/main/testing.md "testing" diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..81709b6 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,327 @@ + + +# Usage + +To be able to use Shellmock, you need to load the library in your tests. +To do so, `load` it in your `setup` function. +Please make sure to load Shellmock in the `setup` function instead of the +`setup_file` function. +You can also test your download of Shellmock and whether it can be loaded like +this: + +```bash +setup() { + load ${PATH_TO_SHELLMOCK_LIBRARY}/shellmock +} + +@test "shellmock can be used" { + shellmock help +} +``` + +Put this in a file called `test.bats` and run it as `bats ./test.bats`, making +sure to replace `${PATH_TO_SHELLMOCK_LIBRARY}` appropriately. + +## Command Reference + + + +You can access all functionality of Shellmock via the `shellmock` command. +It is implemented as a shell function with the following sub-commands: + +- `new`: Create a new mock for an executable. +- `config`: Configure a previously-created mock by defining expectations. +- `assert`: Assert based on previously-configured expectations. +- `global-config`: Configure global behaviour of Shellmock itself. +- `help`: Provide a help text. + + + +The more complex sub-commands will be described below in detail. + +### new + + + +Syntax: `shellmock new ` + +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 `new` command takes exactly one argument: the name of the executable to be +mocked. +For example: + +```bash +shellmock new git +``` + +This will create a mock executable for `git`. +That mock executable will be used instead of the one installed on the system +from that point forward, assuming no code changes `PATH`. + +### config + + + +Syntax: `shellmock config [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. + + + +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. + +Every following argument to the `config` command is a so-called `argspec` (see +below). + +The `config` command can also define a command's standard output. +Everything read from standard input will be echoed by the mock to its standard +output verbatim. +There is no way to have the mock write something to standard error. + +#### Example + +This example simulates a call to `git branch` that: + +- returns with exit code `0` +- expects to be called with the single argument `branch` +- and will output `* main` to stdout + +```bash +shellmock config git 0 1:branch <<< "* main" +``` + +**Note:** The example shows one possible way to define the output of the mock. +In the example it uses a _here string_ to define the input to shellmock. +There are different ways to write to standard input, which even depend on the +used shell. +Here strings are known to work for `bash` and `zsh`, for example. + +#### argspec Interpretation + +An argspec defines expectations for arguments. +Only calls to the mock whose arguments match all given expectations will have +the given exit code and stdout. +Any call to a mock that has at least one argument not matching an argspec will +be considered an error (also see [killparent](#killparent)). + +Note that matches only happen for given argspecs. +That is, if you do not provide an argspec for a positional argument, any value +can be there. +For example, the line `shellmock config git 0` will cause _any_ invocation of +the `git` mock to have a zero exit code, _irrespective of any arguments_ because +no argspecs were given. + +Argspec sets as defined via `config` are matched in order of definition. +The first one found that matches the given arguments will be used by the mock +executable. + +#### argspec Definitions + +There are two _kinds_ of argspecs: exact string matches and regex-based string +matches. +Exact string matches should be preferred whenever possible. + +There are also two _types_ of argspecs: position-dependent ones and +position-independent ones. +Position-dependent argspecs should be preferred whenever possible. + +##### Position-Dependent argspec + +A position-dependent exact string match looks like `n:value` where `n` is a +position indicator and `value` is a literal string value. +This argspec matches if the argument at position `n` has exactly the value +`value`. +For example, the argspec `1:branch` expects the first argument to be exactly +`branch`. +As you can see, counting of arguments starts at 1. +As another example, the argspec `3:some-fancy-value` expects argument 3 to be +exactly `some-fancy-value`. + +Normal shell-quoting rules apply to argspecs. +That is, to specify an argument with spaces, you need to quote the argspec. +We recommend quoting only the value because it is easier to read. +The last example could thus be changed like this: `3:"some fancy value"`. + +Note that you can also replace the numeric value indicating the expected +position of an argument by the letter `i`. +That letter will automatically be replaced by the value used for the previous +argspec increased by 1. +If the first argspec uses the `i` placeholder, it will be replaced by `1`. +Note that `i` must not follow `any` (see below). +Thus, to define the expectation of having the command: + +```bash +git checkout -b my-branch +``` + +You can use the following calls to `shellmock`: + +```bash +shellmock new git +# The first "i" will be replaced by 1 and each subsequent "i" will be one +# larger than the previous one. +shellmock config git 0 i:checkout i:-b i:my-branch +``` + +##### Position-Independent argspec + +A position-independent argspec replaces the position indicator by the literal +word `any`. +Thus, if we did not care at which position the `branch` keyword were in the +first example, we could use: `any:branch`. + +A regex-based argspec prefixes the position indicator by the literal word +`regex-` (mind the hyphen!). +With such an argspec, `value` will be re-interpreted as a _bash regular +expression_ matched via the comparison `[[ ${argument} =~ ${value} ]]`. +You can also use the position indicator `regex-any` to have a +position-independent regex match. +You _cannot_ use `regex-i`, though. + +We _strongly recommend against_ using `regex-1:^branch$` instead of the exact +string match `1:branch` because of the many special characters in regular +expressions. +It is very easy to input a character that is interpreted as a special one +without realising that. + +### assert + + + +Syntax: `shellmock assert ` + +The `assert` command can be used to check whether expectations previously +defined via the `config` command have been fulfilled for a mock or not. +The `assert` command takes exactly two arguments, the `type` of assertion that +shall be performed and the `name` of the mock that shall be asserted on. +We recommend to always use `expectations` as assertion type. + + + +Example: + +```bash +shellmock assert expectations git +``` + +The `assert` command will have a non-zero exit code in case the assertion had +not been fulfilled. + +#### Assertion Types + +There are currently the following types of assertions. + +- `only-expected-calls`: + This assertion will check that the mock has not had calls that had not been + configured beforehand. + That is, if this assertion succeeds, the mock could find a set of argspecs + matching its actual arguments for every time it had been called. +- `call-correspondence`: + This assertion will check that each set of argspecs defined for it had been + used at least once. +- `expectations`: + This assertion will first perform the following assertions in sequence: + `only-expected-calls`, and `call-correspondence`. + It is a convenience assertion type combining all other assertions. + +### global-config + + + +Syntax: + +- `shellmock global-config ` +- `shellmock global-config ` + + + +The `global-config` command can be used to modify Shellmock globally in some +ways. +As argument, `global-config` can have one of the two sub-commands `setval` or +`getval`. + +- With `setval`, you can define some global behaviour. + Using `setval` requires a `value` which the `setting` is set to. +- With `getval`, on the other hand, you can retrieve information about a current + global `setting`. + + + +There are currently the following settings: + +- `checkpath` +- `killparent` + + + +#### checkpath + +Shellmock injects mock executables by prepending a directory that it controls to +`PATH`. +However, the tested code can still make modifications to its `PATH`, which could +cause Shellmock's mocks to not be called. +Thus, every call to the `shellmock` command will check whether the `PATH` +variable has been changed since Shellmock was loaded via `load shellmock`. +An error will be written to standard error in case such a change is detected. + +The default value is 1. +Use `shellmock global-config setval checkpath 0` to disable. +Use `shellmock global-config getval checkpath` to retrieve the current setting. + +#### killparent + +By default, a mock that is called with arguments for which no expectations have +been defined will kill its parent process. +That is, it will send `SIGTERM` to its parent process. +This behaviour means to ensure that tests do not progress past an unexpected +call to the mock. +If the mock were simply to exit with a non-zero exit code, there would be no +difference to defining an non-zero return value. +Such a case could easily be caught by the parent process and cause the test to +take unexpected paths through the code. + +Take the following snippet as an example where we mock `curl`. + +```bash +if ! curl http://some.url; then + echo "Curl command failed, trying different URL" >&2 + curl http://some.other.url +fi +``` + +Assume the `curl` mock did not kill its parent process but we forgot to define +expectations for the first call to `curl`. +That would cause the shell code to enter the `then` branch above, even though we +would rather have our test fail then and there. +To avoid that, we kill the parent process, which means the `then` branch will +not be executed. + +The default value is 1. +Use `shellmock global-config setval killparent 0` to disable. +Use `shellmock global-config getval killparent` to retrieve the current setting. diff --git a/generate_deployable.sh b/generate_deployable.sh new file mode 100755 index 0000000..e6d3b4e --- /dev/null +++ b/generate_deployable.sh @@ -0,0 +1,84 @@ +#!/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. + +# This script generates the shellmock deployable. That is, it generates a single +# file that can be imported to a bats test suite to provide all the +# functionality of shellmock. + +__SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/" &> /dev/null && pwd)" + +deployable() { + # Output header including the licence file. + echo '#!/bin/bash' + sed 's/^/# /' LICENSE + cat << 'EOF' + +# This file is auto-generated. It is the main deployable of shellmock. To make +# contributions to shellmock, please visit the repository under: +# https://github.boschdevcloud.com/bios-bcai/shellmock/ + +EOF + # Output all bats helper files containing function definitions. + for bats_file in lib/*.bash; do + printf -- "\n# FILE: %s\n" "${bats_file}" + cat "${bats_file}" + done + + # Create a function providing the help text. + cat << 'ENDOFFILE' +__shellmock__help() { + "${PAGER-cat}" << 'EOF' +This is shellmock, a tool to mock executables called within shell scripts. +ENDOFFILE + + awk \ + -v start='' \ + -v end='' \ + 'BEGIN{act=0} {if($0==end){act=0}; if(act==1){print}; if($0==start){act=1;};}' \ + ./docs/usage.md + + cat << 'ENDOFFILE' +EOF +} +ENDOFFILE + + # Create a function that outputs the mock executable to its stdout. + cat << 'EOF' + +# Mock executable writer. +__shellmock_write_mock_exe() { +EOF + + echo "cat << 'ENDOFFILE'" + cat ./bin/mock_exe.sh + + cat << 'EOF' +ENDOFFILE +} + +# Run initialisation steps. +__shellmock_internal_init +EOF +} + +main() { + cd "${__SCRIPT_DIR}" || exit 1 + deployable > shellmock.bash +} + +main diff --git a/lib/command_global_config.bash b/lib/command_global_config.bash new file mode 100644 index 0000000..3b567d9 --- /dev/null +++ b/lib/command_global_config.bash @@ -0,0 +1,61 @@ +#!/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. + +# The global-config command that can be used to get and set some global options. +__shellmock__global-config() { + __shellmock_internal_pathcheck + + local subcmd="$1" + local arg="$2" + local val="${3-}" + + case ${subcmd} in + setval) + case ${arg} in + checkpath | killparent) + if [[ -z ${val-} ]]; then + echo >&2 "Value argument to setval must not be empty." + return 1 + fi + local varname="__SHELLMOCK__${arg^^}" + declare -gx "${varname}=${val}" + ;; + *) + echo >&2 "Unknown global config to set: $2" + return 1 + ;; + esac + ;; + getval) + case ${arg} in + checkpath | killparent) + local varname="__SHELLMOCK__${arg^^}" + echo "${!varname}" + ;; + *) + echo >&2 "Unknown global config to get: $2" + return 1 + ;; + esac + ;; + *) + echo >&2 "Unknown sub-command for shellmock global-config: $1" + return 1 + ;; + esac +} diff --git a/lib/mock_management.bash b/lib/mock_management.bash new file mode 100644 index 0000000..081c649 --- /dev/null +++ b/lib/mock_management.bash @@ -0,0 +1,197 @@ +#!/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. + +# Create a new mock for an executable or a function. Functions are unset first +# or they could not be mocked with executables. +__shellmock__new() { + __shellmock_internal_pathcheck + + local exe="$1" + + if [[ $(type -t "${exe}") == function ]]; then + # We are mocking a function, unset it or it will take precedence over our + # injected executable. + unset -f "${exe}" + fi + + __shellmock_write_mock_exe > "${__SHELLMOCK_MOCKBIN}/${exe}" + chmod +x "${__SHELLMOCK_MOCKBIN}/${exe}" +} + +# Configure an already created mock. Provide the mock name, the desired exit +# code, as well as the desired argspecs. +__shellmock__config() { + __shellmock_internal_pathcheck + + # Fake output is read from stdin. + local cmd="$1" + local rc="$2" + shift 2 + + # Validate input format. + local args=() + local has_err=0 + for arg in "$@"; do + if ! grep -qE '^(regex-[0-9][0-9]*|regex-any|i|[0-9][0-9]*|any):' <<< "${arg}"; then + echo >&2 "Incorrect format of argspec: ${arg}" + has_err=1 + fi + args+=("${arg}") + done + if [[ ${has_err} -ne 0 ]]; then + return 1 + fi + + # Ensure we only configure existing mocks. + if ! [[ -x "${__SHELLMOCK_MOCKBIN}/${cmd}" ]]; then + echo >&2 "Cannot configure executable '${cmd}', create mock first." + return 1 + fi + + # Convert incremented arg counters. + local new_arg arg last_count=0 updated_args=() + for arg in "${args[@]}"; do + if [[ ${arg} == "i:"* ]]; then + if [[ -z ${last_count} ]]; then + echo >&2 "Cannot use non-numerical last counter as increment base." + return 1 + fi + last_count=$((last_count + 1)) + new_arg="${last_count}:${arg#i:}" + else + new_arg="${arg}" + # Only use counter as increment base if one was given. + if [[ ${arg%%:*} =~ [0-9][0-9]* ]]; then + last_count="${arg%%:*}" + else + last_count= + fi + fi + updated_args+=("${new_arg}") + done + args=("${updated_args[@]}") + + # Handle arg specs. + local env_var_val + env_var_val=$(for arg in "${args[@]}"; do + echo "${arg}" + done | base64) + + local count=0 + local env_var_name="MOCK_ARGSPEC_BASE64_${cmd}_${count}" + while [[ -n ${!env_var_name-} ]]; do + count=$((count + 1)) + env_var_name="MOCK_ARGSPEC_BASE64_${cmd}_${count}" + done + declare -gx "${env_var_name}=${env_var_val}" + + # Handle fake output. Read from stdin but only if stdin is not a terminal. + if ! [[ -t 0 ]]; then + env_var_val="$(base64)" + else + env_var_val= + fi + env_var_name="MOCK_OUTPUT_BASE64_${cmd}_${count}" + declare -gx "${env_var_name}=${env_var_val}" + + # Handle fake exit code. + env_var_val="${rc}" + env_var_name="MOCK_RC_${cmd}_${count}" + declare -gx "${env_var_name}=${env_var_val}" +} + +# Assert whether the configured mocks have been called as expected. +__shellmock__assert() { + __shellmock_internal_pathcheck + + local assert_type="$1" + local cmd="$2" + + # Ensure we only assert on existing mocks. + if ! [[ -x "${__SHELLMOCK_MOCKBIN}/${cmd}" ]]; then + echo >&2 "Cannot assert on mock '${cmd}', create mock first." + return 1 + fi + + case "${assert_type}" in + # Make sure that no calls were issued to the mock that we did not expect. By + # default, the mock will kill its parent process if an unexpected call + # happens. However, there are cases where that is not desired, which is why + # this assertion is helpful. + only-expected-calls) + if [[ ! -d "${__SHELLMOCK_OUTPUT}/${cmd}" ]]; then + # If this directory is missing, the mock has never been called. That is + # fine for this assert type because it means we did not get any unexpected + # calls. + return 0 + fi + + local has_err=0 + while read -r stderr; do + if [[ -s ${stderr} ]]; then + cat >&2 "${stderr}" + has_err=1 + fi + done < <( + find "${__SHELLMOCK_OUTPUT}/${cmd}" -mindepth 2 -type f -name stderr + ) + if [[ ${has_err} -ne 0 ]]; then + echo >&2 "SHELLMOCK: got at least one unexpected call for mock ${cmd}." + return 1 + fi + ;; + # Check whether each expected call has happened at least once. That is done by + # checking which argspecs are defined for the mock and comparing those to the + # argspecs that were found by then mock when it was being executed. If the + # lists differ, some configured calls have not happened. + call-correspondence) + declare -a actual_argspecs + mapfile -t actual_argspecs < <( + [[ -d "${__SHELLMOCK_OUTPUT}/${cmd}" ]] \ + && find "${__SHELLMOCK_OUTPUT}/${cmd}" -mindepth 2 -type f \ + -name argspec -print0 | xargs -0 cat | sort -u + ) + + declare -a expected_argspecs + mapfile -t expected_argspecs < <( + env | sed 's/=.*$//' | grep -x "MOCK_ARGSPEC_BASE64_${cmd}_[0-9][0-9]*" \ + | sort -u + ) + + local has_err=0 + for argspec in "${expected_argspecs[@]}"; do + if ! [[ " ${actual_argspecs[*]} " == *"${argspec}"* ]]; then + has_err=1 + echo >&2 "SHELLMOCK: cannot find call for argspec: $(base64 --decode <<< "${!argspec}")" + fi + done + if [[ ${has_err} -ne 0 ]]; then + echo >&2 "SHELLMOCK: at least one expected call was not issued." + return 1 + fi + ;; + expectations) + # Run the two asserts defined above after each other. + __shellmock__assert only-expected-calls "${cmd}" \ + && __shellmock__assert call-correspondence "${cmd}" + ;; + *) + echo >&2 "Unknown assertion type: ${assert_type}" + ;; + esac +} diff --git a/lib/shellmock.bash b/lib/shellmock.bash new file mode 100644 index 0000000..d71a997 --- /dev/null +++ b/lib/shellmock.bash @@ -0,0 +1,93 @@ +#!/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. + +# 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 +# so that mocks created by shellmock are used preferentially over others +# installed on the system. It also sets some global, internal configurations to +# their default values. +__shellmock_internal_init() { + local has_bats=1 + if [[ -z ${BATS_RUN_TMPDIR} ]]; then + has_bats=0 + fi + # Modify PATH to permit injecting executables. + declare -gx __SHELLMOCK_MOCKBIN + __SHELLMOCK_MOCKBIN="$(mktemp -d -p "${BATS_RUN_TMPDIR-${TMPDIR-/tmp}}")" + mkdir -p "${__SHELLMOCK_MOCKBIN}" + export PATH="${__SHELLMOCK_MOCKBIN}:${PATH}" + + declare -gx __SHELLMOCK_OUTPUT + __SHELLMOCK_OUTPUT="$(mktemp -d -p "${BATS_RUN_TMPDIR-${TMPDIR-/tmp}}")" + mkdir -p "${__SHELLMOCK_OUTPUT}" + + 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. + __SHELLMOCK_PATH="${PATH}" + # By default, perform checks for changes made to PATH because that can prevent + # mocking from working. + declare -gx __SHELLMOCK__CHECKPATH=1 + # By default, we kill a mock's parent process in case there is an unexpected + # call. + declare -gx __SHELLMOCK__KILLPARENT=1 +} + +# Check whether PATH changed since shellmock has been initialised. If it has +# changed, the shellmock's mocks might no longer be used preferentially. +__shellmock_internal_pathcheck() { + if [[ ${__SHELLMOCK__CHECKPATH} -eq 1 ]] \ + && [[ ${PATH} != "${__SHELLMOCK_PATH}" ]]; then + + echo >&2 "WARNING: value for PATH has changed since loading shellmock, " \ + "mocking might no longer work." + fi +} + +# Main shellmock command. Subcommands can be added by creating a shell function +# following a specific naming scheme. We avoid complex parsing of arguments with +# a tool such as getopt or getopts. +shellmock() { + # Handle the user requesting a help text. + for arg in "$@"; do + if [[ ${arg} == --help ]]; then + set -- "help" + break + fi + done + + local cmd="$1" + shift + + # Execute subcommand with arguments but only if they are shell functions and + # exist. + if [[ $(type -t "__shellmock__${cmd}") == function ]]; then + "__shellmock__${cmd}" "$@" + else + echo >&2 "Unknown command for shellmock: ${cmd}." \ + "Call with --help to view the help text." + return 1 + fi +} diff --git a/tests/from_docs.bats b/tests/from_docs.bats new file mode 100644 index 0000000..6d6c6a4 --- /dev/null +++ b/tests/from_docs.bats @@ -0,0 +1,101 @@ +#!/usr/bin/env bats + +# 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. + +setup_file() { + # Ensure we use the minimum required bats version for the "run" built-in. + bats_require_minimum_version 1.5.0 +} + +setup() { + # Load the downloaded shellmock library. The ".bash" extension is added + # automatically. + load ../shellmock + script=script +} + +# We replace the script with a function to have a self-contained example. +script() { + #!/bin/bash + # Read argument to script. + branch_name="$1" + # Ensure the argument is non-empty. + if [[ -z ${branch_name} ]]; then + echo "Empty argument received." >&2 + # This command always exits with an error. It's the last one executed and, + # thus, its exit code will be the one of this script. It is important not to + # call the "exit" command in scripts that should be easy to test. + false + else + # Check whether the branch exists. + if ! git rev-parse --quiet --verify "${branch_name}"; then + # Branch does not yet exist, create it. + git branch "${branch_name}" + fi + # Check out the branch. It is guaranteed to exist. + git checkout "${branch_name}" + fi +} + +@test "the success case with an existing branch" { + # Shadow original git executable by a mock. + shellmock new git + # Configure the mock to have an exit code of 0 if it is called with the + # rev-parse command. This simulates git reporting that the branch exists. The + # name can be at any position. + shellmock config git 0 1:rev-parse any:some_branch + # Configure the mock to have an exit code of 0 if it is called with the + # checkout command and a specific branch name. + shellmock config git 0 1:checkout 2:some_branch + # Now run your script via the "run" built-in. Here "${script}" contains the + # path to your executable script. We use a shell function here. + run "${script}" some_branch + # Now assert that the calls you expected have indeed happened. If there had + # been an unexpected call, e.g. to " git branch", this line would error out + # and report the problem. + shellmock assert expectations git + # Assert on the exit code. + [[ ${status} -eq 0 ]] +} + +@test "the success case with a missing branch" { + shellmock new git + # Configure the mock to have an exit code of 1 if it is called with the + # rev-parse command for a feature branch. This simulates git reporting that + # the branch does not exist. We match an argument at any position with a bash + # regular expression. + shellmock config git 1 1:rev-parse regex-any:"^feature/.*$" + # Configure the mock to have an exit code of 0 if it is called with the + # branch command and a specific branch name. We match the branch name, which + # is argument 2, with a bash regular expression. + shellmock config git 0 1:branch regex-2:"^feature/.*$" + # The checkout command should also succeed for any feature branch. + shellmock config git 0 1:checkout regex-2:"^feature/.*$" + run "${script}" "feature/some-feature" + shellmock assert expectations git + [[ ${status} -eq 0 ]] +} + +@test "the failure case with empty input" { + # Shadow the original git executable by a mock. This is just to make sure we + # do not call the actual git executable by accident. + shellmock new git + run "${script}" + shellmock assert expectations git + # Assert on the exit code. We expect a non-zero exit code. + [[ ${status} -ne 0 ]] +} diff --git a/tests/main.bats b/tests/main.bats new file mode 100644 index 0000000..56b8cb0 --- /dev/null +++ b/tests/main.bats @@ -0,0 +1,363 @@ +#!/usr/bin/env bats + +# 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. + +# Some of the tests below use a fake executable called "my_exe" as a placeholder +# for any other executable you may want to test. You can test often-used +# executables such as "git", "ls", "find", "curl", "sed", "cat", and any other +# executable you can call from within a shell script. + +setup_file() { + # Ensure we use the minimum required bats version and fail with a nice error + # if not. + bats_require_minimum_version 1.5.0 +} + +setup() { + load ../shellmock +} + +@test "we can mock an executable" { + # Executable not yet present. This test uses a fake executable called + # "my_exe". + run ! command -v my_exe + # Create mock for "my_exe". + shellmock new my_exe + # Configure mock for "my_exe" to have an exit code of 0. Not specifying any + # args means we do not want to make any assertions on args. + shellmock config my_exe 0 + # Executable now present. + command -v my_exe + # Executable can be executed. + my_exe +} + +@test "a concrete example" { + # This concrete example uses the "git" executable with the "checkout" command. + # You could mock it like this analogous to the previous test. + shellmock new git + shellmock config git 0 1:checkout + # Git mock can be called with the checkout command. + git checkout +} + +@test "we can mock a function" { + # When calling an identifier, e.g. "git", the shell will give precedence to + # functions. That is, if there is a function called "git" and an executable in + # PATH of the same name, then the function will be used. The executable will + # be shadowed (same effect in different words). + # + # Shellmock's mocks are executables. Thus, to be able to mock shell functions, + # we need to unset functions we want to mock. Otherwise, the function would + # always shadow our mock executable. This test showcases that. + + # Identifier "my_func" not yet present. + run ! command -v my_func + # Define function. + my_func() { + echo "I am a function" + return 1 + } + # Identifier "my_func" present and is a function. + command -v my_func + [[ $(type -t my_func) == function ]] + + # Create and configure mock. This will automatically unset the function + # "my_func" that we want to mock for the reasons given above. + shellmock new my_func + shellmock config my_func 0 + # The identifier "my_func" is still present but now as an executable file + # (i.e. identified as "type file". The function has been unset automatically + # and no longer shadows our mock executable, as explained above. + command -v my_func + [[ $(type -t my_func) == file ]] + # Mock executable can be executed. + my_func +} + +@test "unexpected call failing mock" { + shellmock new my_exe + # We configure the mock to have an exit code of 0 but only if the first + # argument is "muhaha". Any other argument fails the mock and has it kill its + # parent process. + shellmock config my_exe 0 1:muhaha + + # We succeed when using the expected argument. + my_exe muhaha + # We fail when using an unexpected argument. Call within a separate "sh" + # process to prevent the mock from killing the "bats" executable running the + # tests, which would fail the test suite. Here, the separate "sh" instance + # will be killed instead. + if sh -c "my_exe asdf"; then + # Shell running "my_exe asdf" exits with status code 0, which is a failure + # for this test. + echo >&2 "Call did not fail." + exit 1 + fi +} + +@test "mocks translate to sub-processes" { + shellmock new my_exe + shellmock config my_exe 0 + + sh -c "my_exe" +} + +@test "unexpected call kills parent process unless disabled" { + shellmock new my_exe + shellmock config my_exe 0 1:muhaha + + # Explicitly enable killing the parent process if the mock detects an + # unexpected call. This makes the test fail even if the exit code of the mock + # is ignored by the caller using the "';" in this case. This is the default + # behaviour. + shellmock global-config setval killparent 1 + if output=$(sh -c 'my_exe asdf; echo stuff'); then + # The unexpected call to "my_exe" did not kill the process, the echo was run + # and "sh" exited successfully. That is a failure for this test. + echo >&2 "Call did not fail." + exit 1 + fi + [[ $(shellmock global-config getval killparent) -eq 1 ]] + # Nothing has been written to the output. + [[ -z ${output} ]] + + # Disable killing the parent process if an unexpected call is detected. This + # allows the caller to catch such a case. + shellmock global-config setval killparent 0 + # In this case, the use of ";" causes the exit code of the call to "my_exe" + # not to influence the exit code of the call to "sh". + output=$(sh -c 'my_exe asdf; echo stuff') + [[ $(shellmock global-config getval killparent) -eq 0 ]] + [[ ${output} == "stuff" ]] +} + +@test "non-zero exit code" { + shellmock new my_exe + shellmock config my_exe 2 + + run -2 my_exe +} + +@test "unexpected call being reported" { + # To catch the effect of an unexpected call, we must not kill the parent + # process, i.e. the "bats" executable running the tests. + shellmock global-config setval killparent 0 + + shellmock new my_exe + shellmock config my_exe 0 1:muhaha + + run my_exe asdf + + # Expectations cannot be asserted because they were not fulfilled. Thus, + # ensure that expectations really have not been fulfilled. + if shellmock assert expectations my_exe; then + # Expectations could be asserted successfully, which is a failure in the + # scope of this test. + echo >&2 "Call did not fail." + exit 1 + fi + + # Gather the report for the expectations but ignore the command failing. The + # command will fail as soon as not all expectations could be asserted. The bit + # "|| :" will ignore the exit code of the assertion. The report is written to + # stderr, which means we have to redirect to stdout to capture it. + report="$(shellmock assert expectations my_exe 2>&1 || :)" + + grep "^SHELLMOCK: unexpected call to '.*my_exe asdf'" <<< "${report}" + grep -x "SHELLMOCK: got at least one unexpected call for .*my_exe\." <<< "${report}" +} + +@test "missing call being reported if one configured" { + # To catch the effect of a missing call, we must not kill the parent process. + shellmock global-config setval killparent 0 + + shellmock new my_exe + shellmock config my_exe 0 1:muhaha + + # We deliberately don't call the executable here. + + # Expectations cannot be asserted because they were not fulfilled. Thus, + # ensure that expectations really have not been fulfilled. + if shellmock assert expectations my_exe; then + # Expectations could be asserted successfully, which is a failure in the + # scope of this test. + echo >&2 "Call did not fail." + exit 1 + fi + + # Gather the report for the expectations but ignore the command failing. The + # command will fail as soon as not all expectations could be asserted. The bit + # "|| :" will ignore the exit code of the assertion. The report is written to + # stderr, which means we have to redirect to stdout to capture it. + report="$(shellmock assert expectations my_exe 2>&1 || :)" + + grep "^SHELLMOCK: cannot find call for argspec: 1:muhaha" <<< "${report}" + grep -x "SHELLMOCK: at least one expected call was not issued\." \ + <<< "${report}" +} + +@test "missing call being reported if multiple configured" { + shellmock global-config setval killparent 0 + + shellmock new my_exe + shellmock config my_exe 0 1:muhaha + shellmock config my_exe 0 1:asdf + + # We deliberately call the executable here only once. + my_exe asdf + + if shellmock assert expectations my_exe; then + echo >&2 "Call did not fail." + exit 1 + fi + + report="$(shellmock assert expectations my_exe 2>&1 || :)" + + grep "^SHELLMOCK: cannot find call for argspec: 1:muhaha" <<< "${report}" + grep -x "SHELLMOCK: at least one expected call was not issued\." \ + <<< "${report}" +} + +@test "allow arguments at any position" { + shellmock new my_exe + shellmock config my_exe 0 any:muhaha any:blub + + run my_exe muhaha asdf blub 42 + + shellmock assert expectations my_exe +} + +@test "positional arguments" { + shellmock new my_exe + shellmock config my_exe 0 1:muhaha 2:blub 3:asdf + + run my_exe muhaha blub asdf + + shellmock assert expectations my_exe +} + +@test "positional arguments with gaps" { + shellmock new my_exe + shellmock config my_exe 0 1:muhaha 3:asdf 5:blub + + run my_exe muhaha ANYTHING asdf ANYTHING blub + + shellmock assert expectations my_exe +} + +@test "multiple calls" { + shellmock new my_exe + shellmock config my_exe 0 1:muhaha + shellmock config my_exe 0 1:blub + + my_exe muhaha + my_exe blub + my_exe blub + + shellmock assert expectations my_exe +} + +@test "positional arguments not found" { + shellmock new my_exe + shellmock config my_exe 0 1:muhaha 2:blub 3:asdf + + run my_exe muhaha 42 asdf + + if shellmock assert expectations my_exe; then + echo >&2 "Call did not fail." + exit 1 + fi +} + +@test "arguments determine exit code" { + shellmock new my_exe + shellmock config my_exe 0 1:muhaha + shellmock config my_exe 2 1:asdf + + run -0 my_exe muhaha + run -2 my_exe asdf + + shellmock assert expectations my_exe +} + +@test "providing stdout" { + shellmock new my_exe + # Stdout for a mock is read verbatim from stdin when configuring. Stderr + # cannot be mocked at the moment. This test uses so-called here-strings to + # write a string to stdin of the shellmock command. + shellmock config my_exe 0 1:first_call <<< "This is some output." + shellmock config my_exe 0 1:second_call <<< "This is a different output." + + [[ "$(my_exe first_call)" == "This is some output." ]] + [[ "$(my_exe second_call)" == "This is a different output." ]] + + shellmock assert expectations my_exe +} + +@test "positional arguments matched with bash regexes" { + shellmock new my_exe + shellmock config my_exe 0 regex-1:"[0-9]*_muhaha$" + + # All these calls match the above bash regex. + run -0 my_exe _muhaha + run -0 my_exe 7_muhaha + run -0 my_exe 42_muhaha + run -0 my_exe blub_42_muhaha + + shellmock assert expectations my_exe +} + +@test "arguments matched with bash regexes at any position" { + shellmock new my_exe + shellmock config my_exe 0 regex-any:"[0-9]*_muhaha$" + + # All these calls match the above bash regex. + run -0 my_exe _muhaha + run -0 my_exe ANYTHING ANYTHING 7_muhaha ANYTHING + run -0 my_exe ANYTHING 42_muhaha + run -0 my_exe blub_42_muhaha + + shellmock assert expectations my_exe +} + +@test "easy arg counter increment" { + shellmock new my_exe + # Using "i" as location increases previous counter by 1, starting at 1 for the + # first "i". + shellmock config my_exe 0 i:muhaha i:asdf i:blub + + run my_exe muhaha asdf blub + + shellmock assert expectations my_exe +} + +@test "easy arg counter increment with unsuitable base" { + shellmock new my_exe + # 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 + echo >&2 "Call did not fail." + exit 1 + fi + + [[ ${stderr} == "Cannot use non-numerical last counter as increment base." ]] +} diff --git a/tests/misc.bats b/tests/misc.bats new file mode 100644 index 0000000..3cb06c3 --- /dev/null +++ b/tests/misc.bats @@ -0,0 +1,82 @@ +#!/usr/bin/env bats + +# 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. + +setup_file() { + # Ensure we use the minimum required bats version and fail with a nice error + # if not. + bats_require_minimum_version 1.5.0 +} + +setup() { + load ../shellmock +} + +@test "incorrect argspecs fail the configuration" { + shellmock new my_exe + run ! shellmock config my_exe 0 option-without-position +} + +@test "configuring a non-existent mock fails" { + run ! shellmock config missing_exe 0 any:value +} + +@test "asserting on a non-existent mock fails" { + run ! shellmock assert expectations missing_exe +} + +@test "using an unknown assertion type" { + shellmock new my_exe + run ! shellmock assert unknown-assertion missing_exe +} + +@test "setting or getting an unknown global config" { + run ! shellmock global-config setval unknown-config 1 + run ! shellmock global-config getval unknown-config +} + +@test "setting a global config with an empty value" { + run ! shellmock global-config setval killparent +} + +@test "setting and getting a global config" { + org_val="$(shellmock global-config getval killparent)" + shellmock global-config setval killparent 0 + new_val="$(shellmock global-config getval killparent)" + [[ ${org_val} -eq 1 ]] + [[ ${new_val} -eq 0 ]] +} + +@test "using an unknown subcommand for global config" { + run ! shellmock global-config unknown-command something 1 +} + +@test "using an unknown command" { + run ! shellmock i-am-not-a-known-command +} + +@test "the help command" { + shellmock help +} + +@test "changing PATH after init issues warning" { + export PATH="/I/do/not/exist:${PATH}" + local stderr + run -0 --separate-stderr shellmock new my_exe + local regex="^WARNING: value for PATH has changed since loading shellmock" + [[ ${stderr} =~ ${regex} ]] +}