Unit testing [1]
Hint
Update Bash on macOS [2]
Make sure to use a recent version of bash (for example, the executable
readarray is not available on older versions. Since macOS contains
a very old version of bash (3.2 from 2007) due to a licensing change and
might drop bash entirely in the future, install it manually:
$ brew install bash
When calling bash, the new version is now called. Also add the new bash to your list of trusted shells. Run
$ sudo nano /etc/shells
and add /usr/local/bin/bash to the list.
Important: In case your bash scripts use a shebang line, change it to
#!/usr/local/bin/bash
Installation
Spell Check is a Behavior-Driven Development unit testing framework designed for dash, bash, ksh, zsh and all POSIX shells.
To install it, follow the instructions on https://github.com/shellspec/shellspec#installation.
On macOS, install it via homebrew:
$ brew tap shellspec/shellspec
$ brew install shellspec
On Linux, build the binaries from the sources:
$ git clone https://github.com/shellspec/shellspec
$ cd spellcheck
$ sudo make install
Define the tests
Tests are organized in Specfiles. It comprises of one or multiple test groups. It is advised to use the name of the function a group is supposed to test.
Example:
Describe "FunctionUnderTest"
It "BehaviourUnderTest"
# test code
End
End
Here are the functions, that need to be tested:
array_to_lines() {
local array=("$@")
if (( ${#array[@]} > 0 )); then
printf "%s\n" "${array[@]}"
fi
}
array_sort() {
local array=("$@")
array_to_lines "${array[@]}" | sort -V
}
array_contains() {
local needle="$1"
local haystack=("${@:2}")
local cur
for cur in "${haystack[@]}"; do
if [[ "$needle" == "$cur" ]]; then
return 0
fi
done
return 1
}
The function array_to_lines() returns a array line by line. The criteria to
test are
all input elements are also in the output
the output features the same amount of elements as the input
if the input array is empty, there must not be any standard output
the order of the output elements match the ones in the input array
The function array_sort() sorts an array. The criteria are
no standard output for empty input array
output must be sorted (no digit-wise sorting, since 1,3,20 would become 1,20,3)
all input elements are also in the output
The function array_contains() checks if an array contains a certain element.
The criteria are
elements located at the very first, somewhere in the middle and very last position of an array must be found
no success if reported if element is not found
if the passed in array is empty, the return value must not be 0
This is the resulting .specfile:
Describe "array_to_lines()"
Pending "Not implemented"
It "output each element of the input array"
End
It "amount of output elements match input array element amount"
End
It "don't output anything if input array is empty"
End
It "order of elements is not changed"
End
End
Describe "array_sort()"
Pending "Not implemented"
It "don't output anything if input array is empty"
End
It "each input element is output"
End
It "no digit-wise sorting"
End
End
Describe "array_contains()"
Pending "Not implemented"
It "find element at the very beginning of array"
End
It "find element in the middle of the array"
End
It "find element at the very end of the array"
End
It "return 1 if element was not found"
End
It "return 1 if input array is empty"
End
End
- Describe:
Defines the test group (closed via
End)- It:
Defines a test case (closed via
End)- Pending:
Defines that these tests are supposed to be skipped.
Implement the tests
The first test case for array_to_lines() cannot be implemented with
ShellSpec alone, but requires an additional shell helper function, which is
then called by the framework:
1It "output each element of the input array"
2 _test_array_to_lines_output_complete() {
3 local input
4 local output
5 local input_element
6 input=(
7 0 1 2 3 4 5 6 7
8 "" " " "-" "--"
9 "a" "b" "c" "d"
10 )
11 ausgabe=()
12 readarray -t output < <(array_to_lines "${input[@]}")
13 for input_element in "${input[@]}"; do
14 local -i is_in_output
15 local output_element
16 is_in_output=0
17 for output_element in "${output[@]}"; do
18 if [[ "$input_element" == "$output_element" ]]; then
19 is_in_output=1
20 break
21 fi
22 done
23
24 if (( is_in_output == 0 )); then
25 return 1
26 fi
27 done
28 return 0
29}
30When call _test_array_to_lines_output_complete
31The status should equal 0
32End
- line 2:
Definition of helper function.
- line 12:
Calling readarray which takes the result of
array_to_lines "${input[@]}"as input and saves the result intooutput.- line 13 - 28:
Loop through all input elements and compare them with all output elements. If it is found return 0 otherwise 1.
- line 30:
Calls the
_test_array_to_lines_output_completehelper function. TheWhendirective marks the evaluation procedure.- line 31:
The previous call status is checked (if it is not 0 the test fails). The
Thedirective marks the expectation.Hint
To compare the stdout with a string, use
stdout should equal "my_string".
The next test case, comparing the amount of outputted lines with the amount of elements in the array, can be implemented without an additional helper method:
It "amount of output elements match input array element amount"
When call array_to_lines 1 2 3 4 5
The lines of stdout should equal 5
End
Checking for no output when passing an empty array. It suffices to check for the return code and skip the check on the non-existing stdout:
It "don't output anything if input array is empty"
When call array_to_lines
The status should equal 0
End
To test the order of the output lines, you may use the line directive
of Spell Check:
It "order of elements is not changed"
When call array_to_lines 1 2 3
The line 1 stdout should equal "1"
The line 2 stdout should equal "2"
The line 3 stdout should equal "3"
End
The first two test cases for array_sort() are implemented similarly (the
‘output complete’ test requires another helper method like _test_array_sort_output_complete,
see below for implementation).
The third one, checking for non-digit sorting is implemented as follows:
It "no digit-wise sorting"
When call array_sort 20 1 3
The line 1 of stdout should be equal "1"
The line 2 of stdout should be equal "3"
The line 2 of stdout should be equal "20"
End
Also all test cases needed for array_contains() can be implemented
without helper methods:
Describe "array_contains()"
It "find element at the very beginning of array"
When call array_contains "1" "1" "2" "3"
The status should equal 0
End
It "find element in the middle of the array"
When call array_contains "2" "1" "2" "3"
The status should equal 0
End
It "find element at the very end of the array"
When call array_contains "3" "1" "2" "3"
The status should equal 0
End
It "return 1 if element was not found"
When call array_contains "0" "1" "2" "3"
The status should equal 1
End
It "return 1 if input array is empty"
When call array_contains "0"
The status should equal 1
End
End
Execute the tests
As the methods under test are defined outside of the .specfile the module
framework including the array module must be loaded into it. As specfiles support
regular shell expressions, an include statement before the first
test group is sufficient. An error handling for that is not required as
Shell-Check will report to us in case of errors:
. bms.sh
include "array"
Describe "array_to_lines()"
It "output each element of the input array"
It "output each element of the input array"
local input
local output
local input_element
input=(
0 1 2 3 4 5 6 7
"" " " "-" "--"
"a" "b" "c" "d"
)
output=()
readarray -t output < <(array_to_lines "${input[@]}")
for input_element in "${input[@]}"; do
local -i is_in_output
local output_element
is_in_output=0
for output_element in "${output[@]}"; do
if [[ "$input_element" == "$output_element" ]]; then
is_in_output=1
break
fi
done
if (( is_in_output == 0 )); then
return 1
fi
done
return 0
}
When call _test_array_to_lines_output_complete
The status should equal 0
End
It "amount of output elements match input array element amount"
When call array_to_lines 1 2 3 4 5
The lines of stdout should equal 5
End
It "don't output anything if input array is empty"
When call array_to_lines
The status should equal 0
End
It "order of elements is not changed"
When call array_to_lines 1 2 3
The line 1 of stdout should equal "1"
The line 2 of stdout should equal "2"
The line 3 of stdout should equal "3"
End
End
Describe "array_sort()"
It "don't output anything if input array is empty"
When call array_to_lines
The status should equal 0
End
It "each input element is output"
_test_array_sort_output_complete() {
local input
local output
local input_element
input=(
0 1 2 3 4 5 6 7
"" " " "-" "--"
"a" "b" "c" "d"
)
output=()
readarray -t output < <(array_sort "${input[@]}")
for input_element in "${input[@]}"; do
local -i is_in_output
local output_element
is_in_output=0
for output_element in "${output[@]}"; do
if [[ "$input_element" == "$output_element" ]]; then
is_in_output=1
break
fi
done
if (( is_in_output == 0 )); then
return 1
fi
done
return 0
}
When call _test_array_sort_output_complete
The status should equal 0
End
It "no digit-wise sorting"
When call array_sort 20 1 3
The line 1 of stdout should equal "1"
The line 2 of stdout should equal "3"
The line 3 of stdout should equal "20"
End
End
Describe "array_contains()"
It "find element at the very beginning of array"
When call array_contains "1" "1" "2" "3"
The status should equal 0
End
It "find element in the middle of the array"
When call array_contains "2" "1" "2" "3"
The status should equal 0
End
It "find element at the very end of the array"
When call array_contains "3" "1" "2" "3"
The status should equal 0
End
It "return 1 if element was not found"
When call array_contains "0" "1" "2" "3"
The status should equal 1
End
It "return 1 if input array is empty"
When call array_contains "0"
The status should equal 1
End
End
You may save the upper code in array_spec.sh and execute it via:
$ shellcheck --shell bash --format documentation array_spec.sh
This executes the tests and produces a test run documentation. Lastly, save
the array_spec.sh under /usr/local/share/bms/test/.
Parameterized Tests
Definition
One advantage of Spell-Check in comparison with other shell test framework (like BATS) is that is enables us to perform tests with a lot of different inputs.
For this purpose a new is module is created containing these methods:
is_digits(): checks if string only contains digitsis_upper(): checks if string only contains upper case charactersis_lower(): checks if string only contains lower case charactersis_alnum(): checks if string only contains letters and digits
Create a new file /usr/local/share/bms/include/is.sh and implements the methods:
1is_digit() {
2 local str="$1"
3 if [[ "$str" =~ ^[0-9]+$ ]]; then
4 return 0
5 fi
6 return 1
7}
8
9is_upper() {
10 local str="$1"
11 if [[ "$str" =~ ^[A-Z]+$ ]]; then
12 return 0
13 fi
14 return 1
15}
16
17is_lower() {
18 local str="$1"
19 if [[ "$str" =~ ^[a-z]+$ ]]; then
20 return 0
21 fi
22 return 1
23}
24
25is_alnum() {
26 local str="$1"
27 if [[ "$str" =~ ^[a-zA-Z0-9]+$ ]]; then
28 return 0
29 fi
30 return 1
31}
- line 3 + 11 + 19 + 27:
Compare string with a regular expression.
^and$mark the begin and end of the expression. The=~operator defines a regex match operation between a string on the left side and a regex on the right side.
The is module also requires a constructor (same as array):
__init() {
return 0
}
Execution
To create dynamic parameters for tests, the directive Parameter:dynamic
must be set. Before the next, concluding End, values can be defined
using the special method %data. Those
The tests are defined in /usr/local/share/bms/include/test/is_spec.sh:
1. bms.sh
2include "is"
3
4Describe "is_digits()"
5 Parameters:dynamic
6 for (( i = 32; i < 127; i++ )); do
7 c=$(printf "\\x$(printf '%02x' "$i")")
8 if (( i >= 48 )) && (( i <= 57 )); then
9 %data "accepts '$c'" "$c" 0
10 else
11 %data "accepts '$c' not" "$c" 1
12 fi
13 done
14 End
15 It "$1"
16 When call is_digits "$2"
17 The status should equal "$3"
18 End
19End
- line 7:
Calculates the hexadecimal value of a decimal input (
printf '%02x' "$i") and get the associated ASCII character (c=$(printf "\x<HEX_CODE>" for example :bash:`c=$(printf "\x21"))).- line 9 + 11:
Definition of test positional parameters using the
%dataspecial function. Here, for either the good case ( 48 <= i <= 57, which are ASCII codes for the digits from 0 to 9) and the bad case (i within 32 and 127, which are the printable ASCII character and outside of 48 to 57) three positional values are passed to the test:The test case name (for example “accepts ‘a’”)
The test data value (for example ‘a’)
The return code to assert against (for example 0)
- line 15 - 18:
The test which is supposed to use the upper defined values. The passed in parameter are called via their position, for example
$1for the test case name (there is no $0).
The other tests are similar:
Describe "is_upper()"
Parameters:dynamic
for (( i = 32; i < 127; i++ )); do
c=$(printf "\\x$(printf '%02x' "$i")")
if (( i >= 65 )) && (( i <= 90 )); then
%data "akzeptiert '$c'" "$c" 0
else
%data "akzeptiert '$c' nicht" "$c" 1
fi
done
End
It "$1"
When call is_upper "$2"
The status should equal "$3"
End
End
Describe "is_lower()"
Parameters:dynamic
for (( i = 32; i < 127; i++ )); do
c=$(printf "\\x$(printf '%02x' "$i")")
if (( i >= 97 )) && (( i <= 122 )); then
%data "akzeptiert '$c'" "$c" 0
else
%data "akzeptiert '$c' nicht" "$c" 1
fi
done
End
It "$1"
When call is_lower "$2"
The status should equal "$3"
End
End
Describe "is_alnum()"
Parameters:dynamic
for (( i = 32; i < 127; i++ )); do
c=$(printf "\\x$(printf '%02x' "$i")")
if (( i >= 48 && i <= 57 )) ||
(( i >= 65 && i <= 90 )) ||
(( i >= 97 && i <= 122 )); then
%data "akzeptiert '$c'" "$c" 0
else
%data "akzeptiert '$c' nicht" "$c" 1
fi
done
End
It "$1"
When call is_alnum "$2"
The status should equal "$3"
End
End
To execute the tests, again call shellspec:
$ shellcheck --shell bash --format documentation is_spec.sh