Terminal graphic

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.

Leave a Reply

Your email address will not be published. Required fields are marked *

Learn how your comment data is processed to reduce spam here.

Achieve your goals through technology.

Get in touch to discuss your challenges and goals, we are here to make your business succeed.

First Name
Email
Phone
The form has been submitted successfully!
There has been some error while submitting the form. Please verify all form fields again.

Or, visit our contact page here.

Our Services

Web Development

From simple landing pages to complex web applications, we have you covered.

Operational systems

Automation

By transforming repetitive, manual tasks into automated systems, you can free up precious time to actually run your business.

Maintenance

Some much needed TLC for existing projects.