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.