Unit-Testable Shell Scripts
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
assertion.
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.