Testing shell scripts
I've been scratching an itch lately: I want to test my shell scripts.
I'm a big believer in automation and reproducibility. I test my backend, frontend and even infrastructure code. But shell scripts? They're slippery. They deal in side effects - files, sockets, commands. Thankfully, there’s a tool for that - bats-core
Installation
On Mac you can use brew, but that should work universally:
$ git clone https://github.com/bats-core/bats-core.git
$ cd bats-core
$ sudo ./install.sh /usr/local
Script
Here is a simple utility that can create, delete and list files: file_ops.sh
#!/bin/bash
set -euo pipefail
create_file() {
local file=$1
echo "default content" > "$file"
}
delete_file() {
local file=$1
rm -f "$file"
}
list_files() {
local dir=$1
ls "$dir"
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
"$@"
fi
So the example usage would be something like:
$ ./file_ops.sh create_file /tmp/test.txt
$ ./file_ops.sh delete_file /tmp/test.txt
$ ./file_ops.sh list_files /tmp
Also, here I would like to further explain these two chunks. First, this trio makes your script safer and more predictable. -e would exit immediately if any command returns a non-zero status, -u treat unset variables as an error, -o pipefail if any command in the pipeline fails, the entire pipeline fails (as it should!)
set -euo pipefail
This part means: if the script is being run directly (not sourced), execute the function passed in as arguments. It allows the script to behave both as a library (when sourced) and as a CLI utility (when run).
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
"$@"
fi
Tests
This is a simple test, and requires no mocks. We are going to define temp directory and temp file in our setup() routine. And we are going to delete in in our teardown.
Now let's build some tests.
#!/usr/local/bin/bats
setup() {
TMPDIR=$(mktemp -d)
TMPFILE="$TMPDIR/test.txt"
}
teardown() {
rm -rf "$TMPDIR"
}
@test "create_file creates file with content" {
run ./file_ops.sh create_file "$TMPFILE"
[ "$status" -eq 0 ]
[ -f "$TMPFILE" ]
grep -q "default content" "$TMPFILE"
}
@test "delete_file removes the file" {
echo "temp" > "$TMPFILE"
run ./file_ops.sh delete_file "$TMPFILE"
[ "$status" -eq 0 ]
[ ! -f "$TMPFILE" ]
}
@test "list_files shows files in dir" {
echo "temp" > "$TMPFILE"
run ./file_ops.sh list_files "$TMPDIR"
[ "$status" -eq 0 ]
echo "$output" | grep -q "test.txt"
}
Anatomy of the test
test "create_file creates file with content" {
run ./file_ops.sh create_file "$TMPFILE"
[ "$status" -eq 0 ]
[ -f "$TMPFILE" ]
grep -q "default content" "$TMPFILE"
}
That part defines the test case.
test "create_file creates file with content" { ... }
That will run the command and saves the result. $status - exit code, $output - command's stdout, $lines - array of lines from stdout.
run ./file_ops.sh create_file "$TMPFILE"
First assertion - that the script ran with no errors.
[ "$status" -eq 0 ]
Second assertion - that the file was actually created.
[ -f "$TMPFILE" ]
Third assertion - the file contains the content we expect
grep -q "default content" "$TMPFILE"
Running the tests
To run the test just execute your bats file