Eric Radman : a Journal

Unit-Testable Shell Scripts

Push the Language

It's not surprising that unit testing can increase the qualify of code, but as an engineering discipline it may be less obvious what affect it has on you, the programmer. Even if you're new to an environment, the practice of unit testing quickly prompts you to explore all of the most powerful features of a language. Shell scripting is a great way to learn UNIX, and learning to write unit tests in shell is a great way to learn shell scripting. Even though I had used BSD and Linux for years I was surprised to see how much more concise and expressive shell scripting became after writing unit tests.

Start with a Test Runner

#!/bin/ksh

typeset -i tests_run=0
function try { this="$1"; }
trap 'printf "$0: exit code $? on line $LINENO\nFAIL: $this\n"; exit 1' ERR
function assert {
        let tests_run+=1
        [ "$1" = "$2" ] && { echo -n "."; return; }
        printf "\nFAIL: $this\n'$1' != '$2'\n"; exit 1
}

try "test echo"
# ...
assert "`echo abc`" "abc"

echo; echo "PASS: $tests_run tests run"

My aim is to bend the syntax just enough to create a series of short stories that begin with a description and end with a conclusion. To that end

try is a function that simply stores a test description up front that is displayed if a test fails.
$ ./ut_launcher.sh
.
PASS: 1 tests run

The assert function increments the counter and shows the failed comparison.

$ ./ut_launcher.sh FAIL: test echo 'abC' != 'abc'

If possible, it's much better to replace long function names with strings, which allows us to form an accurate definition of each test. The constraints on function and class names imposes an artificial barrier to naming that is not easily circumvented in most testing frameworks that I'm familiar with. This notation also serves as a very nice summary of the requirements for a particular script which can be extracted with grep or sed

$ cat ut_launcher.sh  | sed -n 's/^try //p'
"clean python search path"
"execuite python with arbitrary argments"
"preserve STDIN"
"use the 'run' command to execute a helper script with arguments"
"return codes are preserved"
"special 'shell' argument should invoke python shell"

The trap 'text' ERR construct will eval the contents of the first parameter if any command exits with a non-zero status. The informative error message really makes the test-code-repeat cycle a lot of fun. Note that single quotes are used so that variable interpolation occurs after the error occurs.

$ ./ut_launcher.sh
./ut_launcher.sh: exit code 1 on line 13
FAIL: test echo

Source Configuration Files

One powerful method of testing the behavior of a shell script is to make it's critical parts configurable through a settings file that is sourced by the main script. Heredocs can also be used to embed other scripts.

# ut_launcher.sh

export LAUNCHER_CONF=`mktemp`

try "clean python search path"
cat > $LAUNCHER_CONF <<-'CONF'
    run=`cat <<'EOF'
    python -c 'import sys; print "\n".join(sys.path)'
    EOF`
CONF
assert "`PYTHONPATH=/proj/xyz ./launcher | grep xyz`" ""

unlink $LAUNCHER_CONF

Notice that if LAUNCHER_CONF is set it will use that, otherwise it will read from a default location. Now we can modify the behavior by generating a custom config before each assert.

eval is a bit tricky because is concatenating each argument to form a single string. When use in conjunction with exec, "$@" can normally be used to preserve arguments that include whitespace characters. With eval we have to single-quote this again to ensure that the expansion happens after the complete string to eval is formed.

# ut_launcher.sh

export LAUNCHER_CONF=`mktemp`

try "clean python search path"
cat > $LAUNCHER_CONF <<-'CONF'
    run=`cat <<'EOF'
    python -c 'import sys; print "\n".join(sys.path)'
    EOF`
CONF
assert "`PYTHONPATH=/proj/xyz ./launcher | grep xyz`" ""

unlink $LAUNCHER_CONF

The use of mktemp will give us a unique name for each test run. If these tests are run sequential we can keep overwriting the same file. When the tests finish we'll clean up by removing the temporary file.

Say What Will Happen

In some cases the most natural approach is to show what action would be taken. The following example sets an environment variable, but reading an option such as --dry-run could also serve the same purpose.

# ut_launcher.sh

export DRY_RUN=1           # echo, do not call exec
try "clean python search path"
assert "`./launcher missile1 missile2 | grep missilelauncher`" \
    "exec launch missile1 missile2"
# launcher

[ $DRY_RUN ] && echo exec launch "$@" || exec launch "$@"
exit $?

Using this method it's certainly possible to echo one thing and

exec another, but this style is symmetrical in appearance and it's easy to see what's happening.

Last updated on November 26, 2016