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 .
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:
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:
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:
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?)