Bats cannot operate on an array variable exported from the function under test

115 Views Asked by At

Bats is great for testing bash scripts, but its documentation is severely lacking in many areas1, including examples and explanations.

I am trying to write a test for a function I've written. However, because the result of this function is a global array, it seems Bats is unable to see it. (This problem may also exist for normal variables, but I could export their contents via stdout.)

#!bats/bin/bats

load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'

_load_filetoarray() {
  # Imagine the source of array data is an external file
  declare -a CREATEDARRAY
  CREATEDARRAY=("aaa" "bbb" "ccc")
  export CREATEDARRAY
}

@test "stackoverflow plain" {
  # Run function that is tested.
  run _load_filetoarray

  # Ensure correct number of lines got loaded
  assert [ "${#CREATEDARRAY[@]}" -eq 3 ]
}

The above file, which provides both the function definition (_load_filetoarray) and the test, produces the below output on Bats v1.10.0.

stackoverflow.bats
 ✗ stackoverflow plain
   (from function `assert' in file test_helper/bats-assert/src/assert.bash, line 40,
    in test file stackoverflow.bats, line 18)
     `assert [ "${#CREATEDARRAY[@]}" -eq 3 ]' failed
   
   -- assertion failed --
   expression : [ 0 -eq 3 ]
   --
   

1 test, 1 failure

If I declare the same function in a separate bash script and run it myself, it works just fine and the array is obviously loaded. But Bats doesn't seem to be able to see the array.

How can I get Bats to see the array so I can write the unit test to verify that I've written the function correctly?

1 Rant about Bats documentation

I'm sorry, it's a great tool, but the documentation is giving me flashbacks to the Bad Old Days of computing, when knowledge was gatekept by Greybeards. It took me YEARS to discover that Bats allowed direct testing of functions, and I only found that out by seeing someone else's test file. I had to back my way into one test that required run bash -c '...' with export -f funcname. Which came first, $lines or $output, because one must be derived from the other and that derivation could lead to problems so I'd like to know. Oh, and the official readthedocs.io tutorial goes incoherent in the middle and starts referencing files that weren't previously introduced.

3

There are 3 best solutions below

7
On

Global arrays or other global variables are better avoided. Pass the output array to your function by “reference” (nameref) instead.

#!/bin/bash
set -euo pipefail

f() {
  local -n a1="$1" a2="$2" output="$3"
  local idx
  for idx in "${!a1[@]}" "${!a2[@]}"; do ((output[idx] = 0)) || :; done
  for idx in "${!a1[@]}"; do ((output[idx] += idx * a1[idx])) || :; done
  for idx in "${!a2[@]}"; do ((output[idx] += idx + a2[idx])) || :; done
}

input_one=([0]=3 [1]=5 [2]=7 [11]=11)
input_two=([7]=2 [5]=1 [3]=0 [11]=11)
result=()

f input_{one,two} result

echo "${result[@]@A}"

The output:

declare -a result=([0]="0" [1]="5" [2]="14" [3]="3" [5]="6" [7]="9" [11]="143")

Stating the obvious, one can now reference f from a bats test and have it modify variables:

@test "stackoverflow plain" {
  local input_one=([0]=3 [1]=5 [2]=7 [11]=11)
  local input_two=([7]=2 [5]=1 [3]=0 [11]=11)
  local result=()

  assert [ -z "${result[*]}" ]
  assert [ -z "${!result[*]}" ]

  f input_{one,two} result

  assert [ "${result[*]}" = '0 5 14 3 6 9 143' ]
  assert [ "${!result[*]}" = '0 1 2 3 5 7 11' ]
}
0
On

After even more research and reading, I've found the answer.

Generally, the way to handle testing an array from a function in Bats is to run a subshell and explicitly echo out the array information I want to test.

I have provided specific examples for array length (echo "${#array[@]}"), an array "dump" (echo "${array[@]}"), and the array line-by-line (for loop with echo $item).

To ensure that the real function exit status is available to Bats, I need to capture it and exit with it, thus the $err code.

#!/bats/bin/bats

load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'

_load_filetoarray() {
  # Imagine the source of array data is an external file
  declare -ag CREATEDARRAY
  CREATEDARRAY=("aaa" "bbb" "ccc")
}

@test "stackoverflow array length" {
  # Run function that is tested.
  export -f _load_filetoarray
  run bash -c '_load_filetoarray ; err=$? ; echo "${#CREATEDARRAY[@]}" ; exit $err'

  # Ensure correct number of lines got loaded
  assert_equal "${output}" 3
  assert_not_equal "${output}" 2
}

@test "stackoverflow array dump" {
  # Run function that is tested.
  export -f _load_filetoarray
  run bash -c '_load_filetoarray ; err=$? ; echo "${CREATEDARRAY[@]}" ; exit $err'

  # Ensure output is correct
  assert_output 'aaa bbb ccc'
  refute_output 'aaa'
}

@test "stackoverflow array lines" {
  # Run function that is tested.
  export -f _load_filetoarray
  run bash -c '_load_filetoarray ; err=$? ; for i in "${CREATEDARRAY[@]}"; do echo "${i}"; done ; exit $err'

  # Ensure output lines are correct
  assert_line --index 0 'aaa'
  assert_line --index 1 'bbb'
  assert_line --index 2 'ccc'
  refute_line 'ddd'
}

The tests all pass.

stackoverflow.bats
 ✓ stackoverflow array length
 ✓ stackoverflow array dump
 ✓ stackoverflow array lines

3 tests, 0 failures

I can confirm that this method works on my real code. For posterity, this is an excerpt of how that was done.

setup_file() {
  # Set variable for test file filename
  testfile="_resources/testfile.log"
  # export required in this case
  export testfile
  # Set variable for testfile length
  testfilelen=$(wc --lines "${testfile}" | cut --delimiter=' ' --fields=1)
  # export required in this case
  export testfilelen
}

@test "loadfiletoarray length" {
  # Load the script that has the function to be tested.
  source ../scriptundertest.sh

  # Run function that is tested.
  export -f _load_filetoarray    # export required in this case
  run bash -c '_load_filetoarray "${testfile}" ; err=$? ; echo "${#CREATEDARRAY[@]}" ; exit $err'

  # Verify result is as expected.
  assert_success  # Ensure no problem loading the file in with `mapfile`

  # These are equivalent in this case
  assert_output "${testfilelen}"
  assert_equal "${output}" "${testfilelen}"

  # If _load_filetoarray outputs something of its own on stdout,
  # I would need to `assert_line --index -1` to test the last output line.
}

(Thanks to @CharlesDuffy, I realized that declare -g was the way to get the array truly global, not export.)

1
On

You need to export it from the function. Here is a modified version of your script:

#!/usr/bin/env bats

load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'

_load_filetoarray() {
   declare -ag CREATEDARRAY=("aaa" "bbb" "ccc")
}

@test "stackoverflow plain" {
   # Execute the tested function.
   run _load_filetoarray

   # Make sure the number of rows loaded is correct
   assert [ "${#CREATEDARRAY[@]}" -eq 3 ]
}

Changes made:

  1. Removed the export statement from the array declaration.
  2. Use declare -ag to declare and export the array in one step.

This should solve the problem of Bats not recognizing arrays. Make sure to replace the #!/usr/bin/env bats line with the correct path to your Bats interpreter.