..

Tests for your bash scripts

I have been working a lot with bash scripts lately. There were a lot of big changes every now and then which would break the script. The obvious thing for any programming project would be to write tests.

I had no idea if it were even possible to write tests for bash scripts, as I had never come across anyone using it. Fortunately, there are a lot of awesome testing frameworks.

One of which is bats, which I used due to it being the most popular and proven one.

First test

Bats has a good and concise documentation which was helpful. But, it’s not very thorough. So, I had to experiment a bit with it before I became familiar with it.

Let’s first install it. On debian based distro, you can simply install it with apt:

sudo apt install bats -y

All the tests file ends with .bats. Let’s first create a simple script. Save it as script.sh:

echo "hello world"

Now, the test:

#!/usr/bin/env bats

@test "hello world" {
  run ./script.sh
  [ "$status" -eq 0 ]
  [ "$output" = "hello world" ]
}

All the tests start with @test followed by it’s name. You can run the script with run and then check for the status code as well as the desired output.

Now, run the test:

bats .

first

What if I wanted to test a function instead? No worries. That’s simple as well.

You can use load command to load the script which has all the functions. Let’s edit our script to instead have a function:

hello_world() {
    echo "hello world"
}

And now, edit the test file:

#!/usr/bin/env bats
load './script.sh'

@test "hello world" {
  run hello_world # notice that we run the function instead of the script
  [ "$status" -eq 0 ]
  [ "$output" = "hello world" ]
}

Instead of using $output, we can use assert_output which is more verbose. It will show you the actual value which can help a lot with debugging while $output will simply return a generic error like this:

generic

Now, to load that, we need to clone submodules from bats repo at your project’s root directory:

git submodule add https://github.com/bats-core/bats-core.git test/bats
git submodule add https://github.com/bats-core/bats-support.git test/test_helper/bats-support
git submodule add https://github.com/bats-core/bats-assert.git test/test_helper/bats-assert

After that, we load it inside a special function called setup(). It’s like init in other languages. Everything inside it will run before the tests.

Time to edit your test:

#!/usr/bin/env bats

setup() {
    load './test/test_helper/bats-support/load'
    load './test/test_helper/bats-assert/load'
    load './script.sh'
}


@test "hello world" {
  run hello_world
  [ "$status" -eq 0 ]
  assert_output "hello world"
}

Now, if we fail the test, here’s what we get:

assert

Sweet! One more thing is that, if we have a large output and just want to grep a specific string, there’s --partial flag for assert_output.

Environment variables

Working with environment variables is quite simple as well. Let’s take a simple example.

I have a clone function which simply clones a github repository. Something like this:

#!/bin/bash

clone_repo() {
  if [ ! -d "$REPO_DIR" ]; then
    if ! git clone "$REPO_URL" "$REPO_DIR"; then
      echo "[*] Error: Failed to clone the repository"
      exit 1
    fi
    cd "$REPO_DIR"
  else
    echo "[*] Repository directory already exists, pulling latest changes..."
    cd "$REPO_DIR"
    if ! git pull; then
      echo "[*] Error: Failed to pull the latest changes"
      exit 1
    fi
  fi
}

I have 2 env variables. I can just export it normally like we do in command line. So, the test would turn out to be something like this:

@test "clone_repo clones the repo" {
  export REPO_URL="https://github.com/bats-core/bats-core.git"
  export REPO_DIR=$(basename "$REPO_URL" .git)

  run clone_repo
  [ -d "$REPO_DIR" ]

  [ "$status" -eq 0 ]
}

We can export it which will pass it to the clone_repo function. At the end, we are checking if REPO_DIR is indeed set so we can use it throughout the script (although I now realize it’s quite useless to even check for it)

Let’s run it:

env

Setting up github actions

Naturally, we would want this to be in the CI and luckily they do have a github actions workflow.

Here’s the workflow I came up with after looking through examples:

name: Bats script testing

on:
  push:
    branches:
      - "main"

permissions:
  contents: read
  packages: write

jobs:
  public_test:
    strategy:
      fail-fast: false
      matrix:
        version: ["latest"]
    runs-on: ubuntu-latest
    env:
      BATS_LIB_PATH: "/usr/lib"
      TERM: xterm
    name: Bats script testing
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Setup Bats and Bats-libs
        uses: bats-core/bats-action@main
      - name: Execute example tests
        run: bats -T -p script.bats

The external modules are in /usr/lib path as mentioned in the README:

For each of the Bats libraries, you can choose to install them in the default location (/usr/lib/bats-) or specify a custom path.

For example, if you want to install bats-support in the ./test/bats-support directory, you can configure it as follows:

# ...
- name: Setup Bats and Bats libs
  uses: bats-core/bats-action@main
  with:
    support-path: $/test/bats-support

If you don’t wanna do that, just edit your test file and use /usr/lib/ path instead.

Along with this, I would also suggest to use shellcheck which is basically a powerful linter for bash scripts:

name: Shellcheck

on:
  push:
    branches:
      - "main"

jobs:
  shellcheck:
    name: Shellcheck
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Run ShellCheck
        uses: ludeeus/action-shellcheck@master
        with:
          severity: error

That’s it. Now your bash scripts won’t break (or will it?)