Getting Started with Bash Testing with Bats

• 11 min read

In this guide I would like to give you an introduction on how you can start writing tests for your Bash scripts by using Bats.

Note: I'm by no means an expert when it comes to writing tests in Bash. All my knowledge comes from maintaining a GitHub Action called git-auto-commit. While I've developed the tests for git-auto-commit, I wished there was a resource which would guide me through all the steps necessary to add a test suite to my bash scripts. That's why this guide exists.

The source code referenced in this guide can be found in this GitHub repository.

Prerequisites

This guide assume that you have basic knowledge of using the shell and about testing code in general. You should also have the npm package manger installed on your machine.
In the later parts of this guide, I also assume that you use Git and host your code on GitHub.

Why and when should you write tests for shell scripts

Writing tests for your projects should give you the confidence that your piece of code does what you expect it do to. Yes, it will take a bit more time to finish the project, but your future self – or another person – will be thankful for those tests.

But writing tests for every single line of code probably doesn't make sense. So my general rule of thumb is as follows:

  • If it's a simple one-off script you will only run once it's probably overkill to write tests for it.
  • If the scripts is shared with the public, should work for future software versions, I will maintain it for the foreseeable future and it isn't a one-liner, it's probably worth investing the time to write a test suite.

If the second statement speaks to you, please read further.

Add Bats to your project

The easiest way to install Bats is by using the npm package manager.
Counterintuitive, right? Why would you use a JavaScript package manager to install a Bash utility?

The answer is simple: npm is a very popular tool among developers. (I would say most developers have it installed on their machine.)
In addition, it comes preinstalled on most continous integration environments. This makes testing your script on a CI service like GitHub Actions much easier. And last, it streamlines the setup process for contributors. They don't have to go through a 10 step install-instructions manual just to write tests. All they have to do is run npm install and they are good to go.

So let's add an empty package.json file to our project by running the following command.

echo "{}" > package.json

Now we can install Bats.

npm install --save-dev bats

To make running the tests easier, we're also adding a scripts block to the package.json file. This allows us to execute our test suite by running npm run test.

 {
   "devDependencies": {
     "bats": "^1.1.0"
   },
+  "scripts": {
+    "test": "bats tests"
+  }
 }

What's missing now is our test file. Let's create it by running:

touch tests/my-first-test.bats

Add the following code inside tests/my-first-test.bats. This is how our very first test looks like:

#!/usr/bin/env bats

@test "It Works" {
    # Arrange
    # Prepare "the world" for your test

    # Act
    # Run your code
    result="$(echo 2+2 | bc)"

    # Assert
    # Make assertions to ensure that the code does what it should
    [ "$result" -eq 4 ]
}

If you're running npm run test in your console, you should see:

> test
> bats tests

 ✓ It Works

1 test, 0 failures

Now, let us install two additional libraries to make writing tests easier.

Install bats-asserts and bats-support

bats-assert and bats-support are two libraries which will make writing Bash tests much more intuitive.

bats-assert gives us an array of helper functions to write assertions that are much easier to process for us humans.

For example for me assert_line 'usage: foo <filename>' is much easier to read and understand than [ "${lines[0]}" = "usage: foo <filename>" ]. (Head over to the bats-assert repository to learn more about the available assertions).

(bats-support is just a supporting library used by bats-assert under the hood.)

To install both libraries run the following commands in your terminal:

npm install --save-dev https://github.com/ztombol/bats-support
npm install --save-dev https://github.com/ztombol/bats-assert

Your package.json file should now look like this.

 {
   "devDependencies": {
     "bats": "^1.1.0",
+    "bats-assert": "ztombol/bats-assert",
+    "bats-support": "ztombol/bats-support"
   },
   "scripts": {
     "test": "bats tests"
   }
 }

To make use of those two libraries we have to load them in our test file. Add the following lines at the top of my-first-test.bats.

#!/usr/bin/env bats

+ load '../node_modules/bats-support/load'
+ load '../node_modules/bats-assert/load'

# ...

You can now also update the assertion in our first test to use assert_equal.

# Assert
# Make assertions to ensure that the code does what it should
- [ "$result" -eq 4 ]
+ assert_equal "$result" 4

If you now run npm run test you should still see that all tests pass.

So we have written a test for a Bash script. Great!
But it's very bare bone right now, right? Let's kick it up a notch.

Writing tests for your existing shell script

In our existing tests we are doing some basic math. The test is also not using code we have written outside of the test case itself.
In a real world project you probably have written your code in a file called main.sh or entrypoint.sh.

Let's update our test suite to run main.sh and then write assertions to make sure, it does what it should.

Let's create a main.sh script.

touch main.sh
chmod +x main.sh

Add the following code inside the newly created main.sh.

#!/bin/bash

touch ./file-{1,2,3}.txt

All our script does is create 3 txt-files in the current directory. Run ./main.sh to see it in action.

Now let's add a test for this. In our my-first-test.bats file add a new test case. I've added comments in the code below to explain what each line does.

# previous test cases …

@test "It creates 3 txt files" {
    # Delete possible existing txt-files
    rm -rf file-*.txt

    # Run our script.
    # We use $BATS_TEST_DIRNAME here, as the tests
    # are executed in a temporary directory. The
    # variable gives us the absolute path to
    # the testing directory
    run "${BATS_TEST_DIRNAME}"/../main.sh

    # Assert that the script has run successfully
    assert_success

    # Execute `ls` to return a list of
    # all the files in the directory
    run ls

    # Assert against the output of `ls` that
    #  file-1.txt, file-2.txt and file-3.txt exist
    assert_output --partial 'file-1.txt'
    assert_output --partial 'file-2.txt'
    assert_output --partial 'file-3.txt'
}

The test should be straightforward to follow:

  1. We remove pre-existing txt-files to ensure, that we don't have any false positives
  2. We run our main.sh script
  3. We assert that the script has run successfully
  4. We execute ls to get a list of all files in the current directory
  5. We assert that the output of ls contains the names of our generated files.

Even though our script and tests are quite simple, this example covers the most common problems I faced while writing tests:

  • How can I execute my own script within a test?
  • How can I write assertions to check that my script works as expected?

In the next step we will add a setup and teardown function to clean the test up.

Add setup() and teardown() to clean up your test

The setup and teardown functions are a common pattern in testing frameworks. PHPUnit has them. Jest has them. And many more frameworks have them too.

These functions give you the ability to run a bit of code before and after a single test case is run. These hooks are commonly used to prepare the "world" for our tests. (See the Bats documentation for details)

Let us add these two hooks to our test suite.

#!/usr/bin/env bats

load '../node_modules/bats-support/load'
load '../node_modules/bats-assert/load'

setup() {
    # Add code which should be executed before each test case
    export MY_SCRIPT_PATH_FOR_NEW_FILES=.
}

teardown() {
    rm -rf ${MY_SCRIPT_PATH_FOR_NEW_FILES}/file-*.txt
}

# test cases …

Our setup function currently exports a MY_SCRIPT_PATH_FOR_NEW_FILES variable, which we will use in our script in a moment.
The teardown function removes all txt-files so that we don't have to repeat ourselfs in every test case.

I've also updated our main.sh script to use MY_SCRIPT_PATH_FOR_NEW_FILES.

#!/bin/bash

touch ${MY_SCRIPT_PATH_FOR_NEW_FILES}/file-{1,2,3}.txt

And our test case has been updated as well.

@test "It creates 3 txt files" {
    # Run our script.
    # We use $BATS_TEST_DIRNAME here, as the tests
    # are executed in a temporary directory. The
    # variable gives us the absolute path to
    # the testing directory
    run "${BATS_TEST_DIRNAME}"/../main.sh

    # Assert that the script has run successfully
    assert_success

    # Execute `ls` to return a list of
    # all the files in the directory
    run ls ${MY_SCRIPT_PATH_FOR_NEW_FILES}

    # Assert against the output of `ls` that
    #  file-1.txt, file-2.txt and file-3.txt exist
    assert_output --partial 'file-1.txt'
    assert_output --partial 'file-2.txt'
    assert_output --partial 'file-3.txt'
}

With this setup you can now create additional test cases to cover all possible edge cases for your script. In your setup function you can prepare your "world" for the tests and in teardown you can clean up your "world" again.

If you want to see a real world example I encourage you to take a look at the tests I've written for git-auto-commit. You can find them on GitHub.

Automatically run test suite on GitHub Actions

Now that we have our test suite ready, we want to make sure that our tests also run in a continuous integration environment.
Or in other words: Let us set up GitHub Actions to run our tests whenever we or a contributor pushes code to the repository or opens a pull request.

If you haven't done already, create a new repository on GitHub for your script. Clone the repository to your machine, add all project files to the repository, create a git commit and push the code to GitHub.

Next, create .github/workflows/test.yml. In this file, we will add our Workflow configuration to run our tests on GitHub Actions.

touch .github/workflows/test.yml

Add the following lines to test.yml.

name: tests

on: push

jobs:
    tests:
        runs-on: ubuntu-latest

        steps:
            - uses: actions/checkout@v2

            - name: Install dependencies
              run: npm install

            - name: Run Tests
              run: npm run test

A quick summary, what this Workflow does.

  • On each git commit push the Workflow is run
  • The repository is checked out
  • Our dependencies (bats, bats-assert and bats-support) are being installed with npm
  • Our test suite is run with npm run test

Now commit your changes and push them to the repository on GitHub. In a matter of seconds your GitHub Action run should start and your tests should run successfully and be "green".

Going further: Mocking with Shellmock

I won't add mocking capabilities to our example project here, but I would like to give a short overview on how you can add Mocking to your Bats tests.
For my git-auto-commit project I've used Shellmock for a period of time, to mock all calls to git. (In hindsight, that was a mistake. Full story can be read here).

First, you need to install Shellmock by running the following commands.

git clone https://github.com/capitalone/bash_shell_mock
cd bash_shell_mock
sudo ./install.sh /usr/local

(Read the projects README if this doesn't work)

Next we need to load – or "source" – Shellmock in our tests. Update setup and teardown like this:

setup() {
    . shellmock
}

teardown() {
    if [ -z "$TEST_FUNCTION" ]; then
        shellmock_clean
    fi
}

. shellmock will load/source Shellmock and make its functions available to us.
‌shellmock_clean will remove various temp files which will be created by Shellmock.

Now we are ready to mock calls to binaries our script is using. In this section of the guide the examples will cover calls to git (as it's the only example I could think of and which I've used in the past).

Imagine our main.sh script does the following.

#!/bin/bash

git add .
git commit -m "This is the way"
git push origin

It stages all changed files in the Git repository, will create a new commit and will push the changes to the remote repository.

In our tests, we don't want to actually create a commit and push them to the repository. It would make our tests rely on a Network connection and would make the tests slow. So we will mock calls to git.

A test case using Shellmock would look like this.

@test "It commits changed files" {
    # Create multiple mocks for git
    shellmock_expect git --type partial --match "add"
    shellmock_expect git --type partial --match 'commit'
    shellmock_expect git --type partial --match 'push origin'

    run "${BATS_TEST_DIRNAME}"/../main.sh

    # Assert that the calls to git happended
    shellmock_verify
    [ "${capture[0]}" = "git-stub add ." ]
    [ "${capture[1]}" = "git-stub commit -m 'This is the way'" ]
    [ "${capture[2]}" = "git-stub push origin" ]
}

In contrast to our other test cases in the rest of this guide, the test is getting quite noisy with the calls to shellmock_expect. The assertions are also not as readable as assert_line or assert_equals.
You also have to explicitly tell the order in which you expect each call to git is going to happen.

That's a lot of brain work. 🙃

In short: Try to avoid mocking calls to other binaries if possible. From experience I can tell you, that your tests will become brittle, because you have to update your tests whenever you update your script.

This will lead to a lot of frustration and wasted time.

Outro

If you've read so far: Congrats! You should now know the basics on how to tests Bash scripts with Bats. 🎉

As a reminder, you can find the source code of the examples mentioned in this guide on GitHub.

If you have questions or suggestions feel free to open a Discussion on the GitHub repository or contact me via Twitter or email.

Webmentions