Unit testing in Bash

From RidgeRun Developer Wiki


Bats

Bats is a TAP- a compliant testing framework for Bash. It provides a simple way to verify that the UNIX programs you write behave as expected.

A Bats test file is a Bash script with special syntax for defining test cases. Under the hood, each test case is just a function with a description.

#!/usr/bin/env bats

@test "addition using bc" {
  result="$(echo 2+2 | bc)"
  [ "$result" -eq 4 ]
}

@test "addition using dc" {
  result="$(echo 2 2+p | dc)"
  [ "$result" -eq 4 ]
}

Bats is most useful when testing software written in Bash, but you can use it to test any UNIX program.

Test cases consist of standard shell commands. Bats makes use of Bash's errexit (set -e) option when running test cases. If every command in the test case exits with a 0 status code (success), the test passes. In this way, each line is an assertion of truth.

Installation

sudo apt install bats

Running a file

Bats will be installed as a system binary, so you can run the tests as follows:

bats ./my_test.bats

Or you can make it an executable as long as you have the following header:

#!/usr/bin/env bats

Tested file considerations

In order to design for testability, the file should be divided into several functions. So that unit tests can target each of these. If there is code that needs is executed by the script when called, but is not necessary for the test, the code should be wrapped in a if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then block.

check_l4t_folder () {
  if [[ -f ${l4t_dir}/flash.sh  ]]; then
      msg "Found all required files"
  else 
      msg "Missing files" && exit 1
  fi

  return 0
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  check_l4t_folder
fi

The previous code block if run directly from an executable will run the check_l4t_folder function, when sourced it will only declare the function which can be used for testing, but not run it immediately.

Checking for return value

In the following code snippet, you'll see two ways of checking for return values. The first will check for the actual return value of the function and its side effects. The second will check for the output of the function (done through a mocked exit function).

###############
# Source file #
###############

die() {
  local msg=$1
  local code=${2-1} # default exit status 1
  msg "$msg"
  exit "$code"
}

parse_params() {
  while :; do
    case "${1-}" in
    -h | --help) usage ;;
    --select_active_partition) 
        if [[ "${2-}" != "A" && "${2-}" != "B" ]]; then
          die "Invalid argument for ${1-}: ${2-}. Should be either A or B"
        fi

        new_active_partition="${2-}"
        shift
    ;;
    -?*) msg "Unknown option: $1" && usage;;
    *) break ;;
    esac
    shift
  done

  return 0
}


#############
# Test file # 
#############

exit() {
    echo $1
}

@test "check_parse_params_select_active_ok" {
    parse_params --select_active_partition A

    # We check the return value by calling $?, this value comes from a "result" call in a function
    ret=$?
    [ "$ret" == 0 ]

    # Functions called outside of a () block will have side effects on the parent function,
    # as such we can check for any variables it set
    [ "$new_active_partition" == "A" ]
}


@test "check_parse_params_select_active_wrong_part" {
    ret=$(parse_params --select_active_partition C)

    # Since we are mocking, the exit will return through an echo, in this case we can
    # check the function result.

    [ "$ret" != 0 ]
}

Mocking binaries

Mocking binaries is a useful technique to avoid unwanted side effects on the system where the test is being run. This for example can avoid the need for superuser privileges or the existence of a given binary.

Mocking system binaries is quite straightforward, just declare a function with the same name as the binary. These mocked binaries will even propagate to the sourced file and not only the test one.

If the file to be mocked is not a system binary, the script should be designed with the ability to have relative paths that can be changed in mind.

###############
# Source file #
###############

run_flash () {
  pushd ${l4t_dir}

  sudo ./flash.sh jetson-tx2 mmcblk0p1

  popd
}

#############
# Test file # 
#############

# Here we mock the sudo utility, we don't need to check for privileges, but still need it to call the argument as a function.
sudo() {
    $1
}

@test "run_flash_ok" {
    source $SOURCE_SCRIPT

    # We use the mktemp utility to create everything in a temporary directory
    l4t_dir=$(mktemp -d)

   # The source file needs a flash.sh script, here we create a binary and make it an executable

    cat > ${l4t_dir}/flash.sh << EOF
#!/usr/bin/env bash

echo 0
EOF
    chmod +x ${l4t_dir}/flash.sh

    # This is the actual function test
    ret=$(run_flash)
    [ "$ret" == 0 ]
}


For direct inquiries, please refer to the contact information available on our Contact page. Alternatively, you may complete and submit the form provided at the same link. We will respond to your request at our earliest opportunity.


Links to RidgeRun Resources and RidgeRun Artificial Intelligence Solutions can be found in the footer below.