Responsibly Writing Bash

Tools of the Trade

I’ve recently made an introductory post on what knowing some bash can do for automating many system tasks. It showed some of what’s possible, but didn’t describe the imperative nature of having the proper ancillary tools in your environment that are crucial to writing quality bash. Beyond your text editor of choice (come to the dark side and use vim or neovim already if you haven’t), you’re going to want these tools.

Shellcheck

You’re going to want a code linter for parsing your scripts for poor choices. For this, shellcheck is the gold standard. It doesn’t just catch bugs, but also calls out poor stylistic code and code that may possibly lead to errors under certain use cases. It will call out many things that are easily missed, even by fairly well seasoned bash scripters. It also works with other shells, such as Korn shell, POSIX shell, and dash. Most linux distributions have shellcheck in their package repositories, so just grab it from there. To use it, just run shellcheck my_bash_script.sh. A particularly salient use case for shellcheck is in CI/CD pipelines to protect your code base from having low quality shell scripts getting pushed to it.

BATS

There are too many people who don’t see the importance of having a unit testing suite for bash. For this purpose, the Bash Automated Testing System (BATS) will serve us very well. We initialize a bats file, which is a bash script that has unique syntax for the purposes of defining test cases. In BATS, a test is a function with an associated description. A basic sample BATS test for a generic hello world function would look as follows:

#!/usr/bin/env bats

@test "Basic function should return 'Hello World'" {
   source $HOME/hello_world.sh 
   run hello
   [ "$output" == "Hello World!" ]
}

Here, hello is a function in the script hello_world.sh that echos “Hello World!”. To run a test, just execute bats test_file.bats in the terminal. If we want more verbose output in TAP format, we run bats with the --tap flag. The command run is used to run a function and a set of commands its given more broadly and then stores the exit status and the output in the variables result and output respectively. If we want to skip a particular test, we can insert the word skip along with an optional comment to let BATS know we want to ignore it. We can also push text to the TAP output via the reserved file descriptor &3. In my test function, I could write this:

echo 'This is some text' >&3

While the BATS core is powerful, albeit fairly limited in functionality, it also has a number of optional libraries that can be downloaded by the user. We’ll make a simple shell script to download and move them to the proper:

#!/bin/sh

mkdir -p "$HOME"/dl-dir

git clone https://github.com/bats-core/bats-support "$HOME"/dl-dir/bats-support
git clone https://github.com/bats-core/bats-assert "$HOME"/dl-dir/bats-assert
git clone https://github.com/bats-core/bats-file "$HOME"/dl-dir/bats-file

sudo cp -r "$HOME"/dl-dir/. /opt/bats-libs/

We can use these libraries in our test scripts by adding the follow lines at the top of the script

#!/usr/bin/env bats

load '/opt/bats-libs/bats-support/load.bash'
load '/opt/bats-libs/bats-assert/load.bash'
load '/opt/bats-libs/bats-file/load.bash'

A rudimentary example of our newly acquired tools like assert looks like this:

#!/usr/bin/env bats

load '/opt/bats-libs/bats-support/load.bash'
load '/opt/bats-libs/bats-assert/load.bash'

@test "An addition test for thee" {
    assert_equal $(echo 2+3 | bc) 5 # Give a command or function and then a space separated expected value
}

@test "Existential test" {
    mkdir -p ~/test
    rmdir ~/test
    refute [ -e '~/test' ] # This has a value of true as long as the directory doesn't exist
}

@test "Simple verification" {
    local a=2
    local b=3
    assert [ "$a" -lt "$b" ]
}

@test "Did the script execute without error?" {
    run test_func
    assert_success # Returns false if the exit code isn't 0
}

@test "Did the script fail?" {
    run test_func2
    assert_failure # Returns false if the exit code is 0
    assert_output -p "Function failed!" # We're giving an output message on failure 
}

Some Other Tools

While shellcheck and BATS can handle most stuff, there are a few other techniques and tools that can be used to debug bash scripts and ensure you’re writing good code. First, you can add set -x to any script to have a line by line print of every command as it’s being executed. You can combine this with the e flag as well to have that script halt when a line fails.