Table of Contents

Test Driven Bash

Historically, shell scripting is derided for thumbing it's nose at best practices. But with a little setup, bash is capable of embracing test driven development. Combined with linting and "strict mode", there are many fewer footguns.

There are two components to ergonomic testing with bash scripts: (1) everything in a function w/ a $(caller) check (2) writing test functions for bats with #@test functions. 1)

Function First

A bash file can check to see if it's being sourced (from another script or from bats) or if it's executed directly . Think python's __name__ = "__main__" idiom. Instead of __name__, we have $(caller) which starts with 0 when executed from a file.

We can make a single bash file both testable while still being able to call it directly to execute commands.

Here's an example file foobar.bash

# NOTE: not calling the function the same as the file
# if the script is in $PATH, bash might endlessly recurse
#   calling the file instead of the function
foobar_(){ echo hi; }
 
# only hit when ./foobar.bash
if [[ "$(caller)" ~= "0 "* ]]; then
     foobar_
fi

Testing

Writing tests is easy! We just need a function with # @test on the same line as the function definition. 2).

test_foobar() { #@test
  run foobar_
  [[ $output == "hi" ]]
}

With that appended to foobar.bash we can test it like

 bats --verbose-run

and run it like

 ./foobar.bash 

Integrating Workflow

Writing like this takes a bit of joy out of using bash. There's boiler plate. That's for java and python programming. To avoid the buzzkill, I use a snippet (yasnippet, ultrisnips) on the shebang of bash scripts to make setup painless. See mine for vim and emacs

Example

# spot the bug
getid(){ grep -oP '\d{5}_\d{3}_\d{2}' <<< "$*" | sed 's/_/ /'; }
 
function test_getid { #@test
  run getid fake/path/sub-98765_123_45.nii.gz
  [[ $output == "98765 123 45" ]]
}
 
[[ "$(caller)" != "0 "* ]] || getid "$@"

(missing “global” modifier in sed: s///g)

Convenience Utilities

iffmain from lncdtools also adds unoffical strict-mode (set -euo pipefail) and trap

mainfunc() { echo "hi"; }
 
eval "$(iffmain "mainfunc")"
 
mainfunc_test(){ #@test
  run mainfunc
  [[ $output =~ ^h ]]
}
1)
Possible 3rd: identifying when a test is appropriate.
2)
long from function definition syntax function foobar_ { #@test also works