BATS

Unit testing BASH

Although BASH predominantly works with other applications by way of invocation of other installed applications, I’d suggest that ANY code we write should be provable and testable.

What is BATS?

Any code, even basic scripts, are subject to human error and bugs and as such, we should be able to prove functionality in an isolated environment, free from any form of influence. Bash Automated Testing System, or BATS, is a testing framework designed to be able to test Bash scripts.

Installation

BATS is an NPM package, and can be installed by npm install -g bats. For those using MacOS, there is a Homebrew recipe, brew install bats-core.

There is also a Docker container, which can be run with docker run -it bats/bats:latest

We will also make use of the “BATS Assert” library to simplify our assertions, npm install -g bats-assert.

Examples

BATS uses a special syntax and file extension for test cases, generally following the following format in a <FILENAME>.bats file:

@test "<TEST CASE LABEL>" {
<SETUP SCRIPTING>

<CONDITIONAL OR CODE THAT RETURNS BOOL>
}

So, let’s start with a simple “hello world”. We start with the descriptive label, “Hello World”. Then we write the test case setup script, in this case setting a variable. Finally, we assert that our variable matches an expectation in a familiar bash conditional format.

@test "Hello World" {
some_string=$(echo "Hello World!")
[[ "$some_string" == "Hello World!" ]]
}

Let’s see what we can do with JSON, which we will often receive -back from an API request.

@test "JSON parsing with JQ" {
api_response = "[{\"id\": 1, \"post_title\": \"Foo bar\"}]"
post_id=$(echo $api_response | jq ".[0].id")
[ "$post_id" -eq 1 ]
}

We can ascertain whether an error is thrown in a script, for this we will include the bats-assert library. Let’s say we don’t want Dave, specifically, to execute a function…

# script.bash
example_script_status_code()
{
local given_name=$1
if [[ "$given_name" == "Dave" ]]; then
exit 1
fi
}

# test.bats
load "$NVM_DIR/versions/node/v21.5.0/lib/node_modules/bats-assert/load"

@test "Ensure that Dave can't do something" {
source ./script.bash
run example_script_status_code "Dave"
assert_failure
}

Assertions

Like most testing frameworks, BATS can assert on function output in a variety of ways, two common ways is by using a standard Bash conditional, or the Bash-Assert library provides a number of functions that are basically syntactic sugar.

Bash conditional

@test "Basic bash conditional" {
response=$(echo "Foo")
[[ "$response" == "Foo" ]]
}

assert

Assert that an assumption is as expected.

@test "BATS-ASSERT assert" {
assert [ 1 -eq 1 ]
}

refute

Test that an assumption is false

@test "BATS-ASSERT refute" {
refute [ 1 -eq 0 ]
}

assert_equal

Test two entities are the same

@test "BATS-ASSERT assert_equal" {
assert_equal "foo" "foo"
}

assert_success

Fails if the exit code is not 0

@test "BATS-ASSERT assert_success" {
run bash -c "echo \"Foo\"; exit 0"
assert_success
}

assert_failure

Fails if the exit code is 0

@test "BATS-ASSERT assert_failure" {
run bash -c "echo \"Foo\"; exit 1"
assert_failure
}

assert_output

Fails if $output does not match the assertion

@test "BATS-ASSERT assert_output" {
run echo "Foo"
assert_output "Foo"
}

You can assert on a partial $output response too with the --partial flag

@test "BATS-ASSERT assert_output --partial" {
run echo "Lorem ipsum dolor"
assert_output --partial "ipsum"
}

And also against a regular expression with the --regexp flag

@test "BATS-ASSERT assert_output --regexp <PATTERN>" {
run python --version
assert_output --regexp '^Python 3+\.[0-9]{1,3}+\.[0-9]{1,3}$'
}

Mocking

In unit testing, it’s super important to isolate your functionality and tests so that you only explicitly test what you have written. This means that, for example, you should never, ever have any reason to test an external API, only that your script can work predictably with a known input. In the below example, we will mock an API call and assert on our Bash function’s execution given a known input.

First, we need our bash function where we will poll an API to retrieve a user.

# user.bash

get_user() {
resp=$(curl -X GET <https://jsonplaceholder.typicode.com/users/"$1")>
echo $resp

exit 0
}

Next we need our BATS file where we test our function.

# test_user.bats

@test "Mocking API response" {

}

We need to bring in our get_user function.

# test_user.bats

@test "Mocking API response" {
source ./user.bash
}

Next, we write our assertion, where we will check that the user is as expected based on an understanding of the data’s shape, as well as that the function executed successfully.

# test_user.bats

@test "Mocking API response" {
source ./user.bash

user_name=$(get_user 1 | jq .name)
assert_success

expected_user_name="Leanne Graham"
[[ "$user_name" == \"$expected_user_name\" ]]
}

Now, we are still polling an actual API, so now let’s mock that by creating an example response and override cURL.

# mock_api_user_response.json

{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "Sincere@april.biz",
"address": {
"street": "Kulas Light",
"suite": "Apt. 556",
"city": "Gwenborough",
"zipcode": "92998-3874",
"geo": {
"lat": "-37.3159",
"lng": "81.1496"
}
},
"phone": "1-770-736-8031 x56442",
"website": "hildegard.org",
"company": {
"name": "Romaguera-Crona",
"catchPhrase": "Multi-layered client-server neural-net",
"bs": "harness real-time e-markets"
}
}
# test_user.bats

@test "Mocking API response" {
source ./user.bash

curl() {
cat ./mock_api_user_response.json
}
export -f curl

user_name=$(get_user 1 | jq .name)
assert_success

expected_user_name="Leanne Graham"
[[ "$user_name" == \"$expected_user_name\" ]]
}

Finally, we need to return cURL to it’s original state, otherwise all following executions will simply use the mocked version.

# test_user.bats

@test "Mocking API response" {
source ./user.bash

curl() {
cat ./mock_api_user_response.json
}
export -f curl

user_name=$(get_user 1 | jq .name)
assert_success

expected_user_name="Leanne Graham"
[[ "$user_name" == \"$expected_user_name\" ]]

unset curl
}

And there you have it. Trivial to implement but often overlooked, testing your scripts are an extremely important way to verify that what you’ve written does the job it’s meant to.