How to mock in Bash tests
Mock using aliases, functions, and PATH override
For Bash scripts, almost everything is about side-effects. They typically work with system resources
such as the network or the file system. Commands that behave differently based on external factors
factors---like du
or ls
---have to be controlled by the test to ensure reproducibility. This post
covers 3 alternatives to mocking: using aliases, functions, and PATH
override. Live examples
can be found in the testing-in-bash/mocking
GitHub repo.
To illustrate it, let's see the improved hello
function that greets users differently on Fridays.
function hello() {
local username=$1
if [[ "$(date +%A)" == "Friday" ]]; then
echo "What a wonderful day, $username!"
else
echo "Hello, $username"
fi
}
A test for this function would return different results depending on the day it's executed.
result=$(hello "John")
# 'result' is hard to test because it's depending on the current date
To get the same results on every day the date
function has to be mocked.
Mocking with aliases
In his post, Dave Nicolette recommends alias to create mocks.
By default, aliases are only expanded in interactive shell sessions. In scripts, this behavior has to be explicitly enabled with
the expand_aliases
shell option using shopt
# Enable expanding aliases in this script
shopt -s expand_aliases
# Set up mock
alias "date"="echo 'Friday';true"
# Include the function to be tested
source greeting.sh
# Execute test case
result=$(hello "John")
# result is "What a wonderful day, John!"
Note that defining the mocks and sourcing the script to be tested can be done in any order, the script will use the alias over the original command.
One limitation of this approach is that aliases can't have arguments so they can only respond in one predefined way. If
greeting.sh
would use date
in multiple places, I'd be in trouble with this approach.
Also, in this concrete example, the script under test calls date +%A
, but it's
impossible to narrow the alias down to these arguments
and have something like alias "date +%A"="..."
.
Another problem is that the alias has to take care of the potential arguments that are passed to it. For example, the following simple alias would only work in the simplest case where the mock does not receive any arguments.
# Setup simple alias
alias "date"="echo 'Friday'"
# Without arguments, it works nicely
date
→ Friday
# But arguments are also echoed back.
date +%A
→ Friday +%A
This is obviously not ideal. I've added true
to the alias to swallow the extra arguments.
alias "date"="echo 'Friday';true"
date
→ Friday
date +%A
→ Friday
Mocking with function definitions
For defining mocks a more robust approach is to use functions, as recommended in this post.
# Include the function to be tested
source greeting.sh
# Set up mocks
function date() {
echo "Friday"
}
export -f date
# Execute test case
result=$(hello "John")
# result is "What a wonderful day, John!"
In this case, exporting the mocks should happen after importing the script to be tested.
By using functions rather than mocks all the previously mentioned problems are solved. Functions ignore additional parameters,
so there's no need to worry about the extra +%A
in our case. Moreover, it's also easy to create complex mocks that can
respond in multiple ways depending on the parameters.
Note: the export -f
is to ensure that sub-shells can also use the mock.
However, even with functions things can get complicated if a single mock function has to mock all usages for a common command,
like date
. This can quickly result in huge, hard-to-maintain mock code.
A better practice is to refactor the original script a bit, wrap low-level commands in higher-level functions that capture part
of the business domain, and simply mock that. For example, a potential improvement in the greeting.sh
could be to introduce
the day_of_week
function.
function hello() {
local username=$1
if [[ "$(day_of_week)" == "Friday" ]]; then
echo "What a wonderful day, $username!"
else
echo "Hello, $username"
fi
}
function day_of_week() {
date +%A
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
hello "John"
fi
It also improves code readability since it's much easier to figure out what date +%A
does.
Now it's possible to mock day_of_week
instead of date
, which will result in a much simpler mock, and less chance of
accidentally mocking something we'd not want to. It's important to keep in mind that mocks can change the behavior of the
tests too. For example, mocking date
could very easily interfere with the tests if the test framework uses that command for reporting.
Aliases and custom functions only work with unit tests. They can only override commands and functions if the code of the script file is sourced into the unit test. They have no effect when a script file is simply executed.
Mocking with PATH
override
However, in this case, it's still possible to mock commands by overriding the PATH
variable,
supplying custom executables.
A similar mock with the previous functionality could be defined by creating a date
executable with the following contents:
#!/bin/bash
echo "Friday"
Such mock can be used by putting this executable to the highest precedence on PATH
:
export PATH=mocks:$PATH
result=$(./greeting.sh "John")
# result is "What a wonderful day, John!"
The PATH
based mocking provides a bit less than the alternatives.
First, it only works with external executables, it does not allow mocking functions defined in the script file we are testing.
Additionally, a separate file has to be maintained for each mock, which makes them separated from the test cases where they are used, thus probably harder to maintain.
Also while it's still easy to return a dummy response from such mock, it's a bit harder to set up expectations about how the mock was called.
Bonus: Mocking stdin, asserting stdout, and stderr
A script might work with the stdin, stdout and stderr data streams to interact with the user.
#!/bin/bash
# Print question to stderr
echo "Please enter your name:" >&2
# Request input from stdin
read name
# Print response to stdout
echo "Hello, ${name}!"
Luckily, it's really easy to test them using pipes and redirection.
# Test that it asks for the name
prompt_on_stderr=$( printf "any\n" | ./greet.sh 2>&1 >/dev/null )
# prompt_on_stderr is "Please enter your name:"
result=$( printf "John\n" | ./greet.sh 2>/dev/null )
# result is "Hello John!"
In the first test to capture the stderr instead of stdout with command substitution I've redirected the stderr to the stdout,
and stdout to /dev/null
. In the second test where only the stdout was needed, I've redirected stderr to /dev/null
to silence
everything that comes out of the greet.sh
.
Also, I used printf
instead of echo
to explicitly control the new lines. This can come in handy if the script expects
multiple lines on the stdin.
Conclusion
Unit testing Bash scripts can greatly enhance developer experience. Because Bash scripts are all about side effects, mocking is an important part of effective testing. In this post we've covered 3 alternatives that makes this possible.