How to test Bash scripts (2020 guide)

Published 1 Dec 2020 - 5 min read

How do you test your bash scripts? Do you test them at all?

Bash scripting is one of the handiest tools every developer has to automate tasks, ease the everyday activities, help to reduce the toil. But automation tools can actually increase the toil if are not treated the same way as code. Bash scripts can get out of date and stop working, became hard to maintain, and increase your technical debt. You need to check them in version control, write tests, and use CI.

In this article, we’ll go through how you can improve the quality of your automation scripts writing tests for Bash.

Bach

Bach is a testing framework for Bash that provides the possibility to write unit tests for your Bash scripts. Bach makes any command in the PATH an external dependency of the tested Bash script so that no command will be actually executed but will run as “dry run”. In this way, you will be able to test the logic of the script and not the commands themself. Bach mocks all commands by default and provides a set of APIs for executing real commands if necessary.

How to install Bach

Prerequisites

To install Bach Testing Framework download bach.sh to your project, use the source command to import bach.sh.

For example:

source path/to/bach.sh

What can be done

In Bach, we can test what the Bash script will actually execute.

Every test case in Bach is made of two functions: one for running tests and the other for asserting.

When you run your tests Bach will execute the two functions separately and will compare the sequence of commands executed by both functions. Every testing function must start with the name test-, the asserting function must end with -assert.

How to write test cases

Let’s see some practical examples of how to write test cases.

#!/usr/bin/env bash
set -euo pipefail
source bach.sh

To enable the Bach Testing Framework the first thing to do is to source the bash.sh.

test-rm-your-dot-git() {
    @mock find ~ -type d -name .git === @stdout ~/src/your-awesome-project/.git \
                                                ~/src/code/.git
    find ~ -type d -name .git | xargs -- rm -rf
}
test-rm-your-dot-git-assert() {
    rm -rf ~/src/your-awesome-project/.git ~/src/code/.git
}

The first script we test is a command to find all the .git folders and remove them. We can test it by mocking the find command with those parameters to output two directories.

test-mock-script-with-custom-complex-action() {
    @mock ./path/to/script <<\SCRIPT
if [[ "$1" == foo ]]; then
  @echo bar
else
  @echo anything
fi
SCRIPT
    ./path/to/script foo
    ./path/to/script something
}
test-mock-script-with-custom-complex-action-assert() {
    bar
    anything
}

In the second script, we use Bach to test a script that returns a different output based on the input. In this case, we mock the script using @mock ./path/to/script and then we define the script behavior.

test-bach-framework-mock-commands() {
    @mock find . -name fn === @stdout file1 file2

    ls $(find . -name fn)

    @mock ls file1 file2 === @stdout file2 file1

    ls $(find . -name fn) | xargs -n1 -- do-something
  
    @mock ls === @stdout foo bar foobar
    ls | xargs -n2 -- bash -c 'do-something ${@}' -s
}
test-bach-framework-mock-commands-assert() {
    ls file1 file2

    do-something file2
    do-something file1

    bash -c 'do-something ${@}' -s foo bar
    bash -c 'do-something ${@}' -s foobar
}

In this script, you can see how to perform some complex operations like mocking a command and execute it in a $(...) expression and using pipes.

test-bach-framework-set--e-should-work() {
    set -e

    do-this
    builtin false

    should-not-do-this

}
test-bach-framework-set--e-should-work-assert() {
    do-this
    @fail
}

Here we test the behavior of set -e so we make the script fail with builtin false and we test the failure using @fail.

test-no-double-quote-star() {
    @touch bar1 bar2 bar3 "bar*"

    function cleanup() {
        rm -rf $1
    }

    # We want to remove the file "bar*", not the others
    cleanup "bar*"
}
test-no-double-quote-star-assert() {
    # Without double quotes, all bar files are removed!
    rm -rf "bar*" bar1 bar2 bar3
}

test-double-quote-star() {
    @touch bar1 bar2 bar3 "bar*"

    function cleanup() {
        rm -rf "$1"
    }

    # We want to remove the file "bar*", not the others
    cleanup "bar*"
}
test-double-quote-star-assert() {
    # Yes, with double quotes, only the file "bar*" is removed
    rm -rf "bar*"
}

Here we define a function that takes an argument and removes it. In the first example, we don’t use double quotes in the function, this will remove all the files starting with bar-. In the second example, the function is using double quotes so only the file called bar* is removed.

How to run Bach tests

You can write all your test cases in a single .sh file remembering to add the source bach.sh in it. At this point to run the tests you just need to execute the .sh file.

This is it!

In this post, we’ve seen how to write unit tests for your Bash scripts to improve the quality and reliability of your automation.

Reach me on Twitter @gasparevitta and let me know your thoughts!

Get emails about new articles!


I write about Continuous Integration, Continuous Deployment, testing, and other cool stuff.
Gaspare Vitta on Twitter