Testing bash scripts

A few years ago, I was looking into the idea of writing unit testing for bash scripts, and that's when I stumbled across a project called bats.

Coming from a Perl background, It caught my attention due to its use of the TAP protocol (Test Anything Protocol). However the opensource project didn't seem active and also my needs and attention have also shifted elsewhere.

Doing a similar search this week led me to a Stack Overflow answer that refered to a blog post:

and went on to mention that BATS has a maintained fork nowadays: bats-core

That's good news. The only things I need to find out now is how to export functions out of a bash script. Because my plan is to move the core functionality into a function, which I export into a wrapper script for normal operation, and into a test script so I can make test assertions using the aforementioned test framework.

This was answered on another Stack Overflow page, so that we can do something like this:

#!/usr/bin/env bash

do_stg {
...
}
./core.sh
#!/usr/bin/env bash

source ./core.sh

do_stg()
./run.sh
#!/usr/bin/env bats

source ./core.sh

@test "can do something" {
  result="$(do_stg())"
  [ "$result" -eq ... ]
}
./test.sh

Setup and real example

There are various ways to install bats-core in a project. On a mac, an easy way is using homebrew.

$ brew install bats-core

Here is something about a new project I'm working on: There is a bug, I created a GitHub issue for it, I  have reproduced the bug locally, and then I wrote a test with bats-core:

#!/usr/bin/env bash

setup () {
	./preview 1> /dev/null 2> /dev/null
}

@test "link to homepage on other page is not empty" {
	link=$(docker-compose run --rm export sh -c "wget -qO- http://preview/404/" | grep "gh-head-logo" | cut -d"=" -f3)
	[[ $link =~ http://localhost:9999 ]]
}
bug-19-homepage-link-empty.bats

Then I run the suite:

$ bats tests
bug-19-homepage-link-empty.bats
 ✗ link to homepage on other page is not empty
   (in test file tests/bug-19-homepage-link-empty.bats, line 6)
     `[[ $link =~ http://localhost:9999 ]]' failed
   Creating ghost-ssg_export_run ... 
   Creating ghost-ssg_export_run ... done
pages_repo_setup.bats
 - noop if local repo exists (skipped: wrong branch)

2 tests, 1 failure, 1 skipped

That's good, because TDD tells us that we should start with a failing test (it's actually red colored on my terminal). You can notice another test, pages_repo_setup.bats is present, which is a new test I created for another issue but I am on the wrong branch for it, since I'm focused on this bug, so I marked it to be skipped.

Then, after I've fixed the bug, I can run the test suite again:

$ bats tests
bug-19-homepage-link-empty.bats
 ✓ link to homepage on other page is not empty
pages_repo_setup.bats
 - noop if local repo exists (skipped: wrong branch)

2 tests, 0 failures, 1 skipped

Good, the test is now green!

Just so you get an idea how it works with a code organisation I sketched at the beginning, I'll show you the other test (the skipped one):

#!/usr/bin/env bats

source ./src/lib/repo_setup.sh

teardown () {
	if [ -f "tests/gitlab" ];then
		rmdir "tests/gitlab"
	fi
}

@test "noop if local repo exists" {
	skip "wrong branch"
	baseDir="tests"
	mkdir "$baseDir/gitlab"
	existingRemoteRepo="https://gitlab.com/user/myblog.git"
	create_dir_for_repo $baseDir $existingRemoteRepo
	[ $? -eq 0 ]

}

Here, the code being tested is the create_dir_for_repo() function defined in the file src/lib/repo_setup.sh which will be used in the main script too. You can also see from the two example test cases, that you have the traditional setup() and teardown() functions at your disposal, and from a cursory look at the documentation, I noticed other features that I'm used to see in test frameworks for "proper" programming languages.

Finally, here's a partial view on the  directory strucure of the project:

├── src
│   └── lib
│       └── repo_setup.sh
├── stage
├── tests
│   ├── bug-19-homepage-link-empty.bats
│   └── pages_repo_setup.bats
├── up
├── versions
└── whatsup

The  tests parameter that was passed to the bats command is the directory where the tests are located.

Finally, we look into setting up continuous integration for this project. Using GitLab as CI server, here's the configuration to get this working (notice how we use another method - NPM - to install bats-core):

image: docker:latest
  

services:
  - docker:dind

before_script:
  - apk add npm bash
  - npm install -g bats

test:
  script:
    - cp env-sample .env
    - cp config.production.json.sample config.production.json
    - ./up
    - bats tests
.gitlab-ci.yml

Then we can check the GitLab pipeline log:

...
$ bats tests
1..1
ok 1 link to homepage on other page is not empty
Cleaning up project directory and file based variables 00:00
Job succeeded