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.

May 19, 2020
In this article