shUnit2 - Testing AWS CLI scripts
Assumptions
- The script-under-test is simple and short and does not require its own file system (mock it or run in Docker)
- Commands to be mocked are not addressed by their full path (such scripts can usually be refactored to utilise
$PATH
)
Set up
Project structure
- Tree
1
2
3
4▶ workspace .
├── script.sh
└── shunit2
└── test_script.sh
Running tests
Run
1
bash shunit2/delete_stack.sh
Locate the script-under-test, add a line like this at the start of every test file:
1
script_under_test=$(basename "$0")
Installing shUnit2
- You may take it from the master branch using something like this:
1
2
3curl \
https://raw.githubusercontent.com/kward/shunit2/6d17127dc12f78bf2abbcb13f72e7eeb13f66c46/shunit2 \
-o /usr/local/bin/shunit2
Mocks
The script can be divided into
- commands related to the internal logic of the script
- commands related to the external behaviour of the script (things it changes or whatever it actually does)
- The commands_log created by the mocks can be queried to make assertions about the script’s actual behaviour
A simple mock that just silently intercepts and logs the inputs passed into it (copy/past style) looks like:
1
2
3
4
5
6
7
8# ${FUNCNAME[0]} in Bash is the name of a function
chmod() {
echo "${FUNCNAME[0]} $*" >> commands_log
}
chown() {
echo "${FUNCNAME[0]} $*" >> commands_log
}A more complicated mocks can respond with fake responses:
1
2
3
4
5
6
7some_command() {
echo "${FUNCNAME[0]} $*" >> commands_log
case "${FUNCNAME[0]} $*"
"${FUNCNAME[0]} some_arg_a some_arg_b") ; echo some_response_1 ;;
"${FUNCNAME[0]} some_arg_c some_arg_d") ; echo some_response_2 ;;
esac
}The
tearDown
function provided by shUnit2 is later expected to clean up the commands_log1
2
3tearDown() {
rm -f commands_log
}
Learn by example
Cloudformation
Cloudformation script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
usage() {
echo "Usage: $0 STACK_NAME S3_BUCKET"
exit 1
}
delete_all_artifacts() {
aws ec2 delete-key-pair \
--key-name "$stack_name"
aws s3 rm --recursive --quiet \
s3://"$s3_bucket"/deployments/"$stack_name"
}
resume_all_autoscaling_processes() {
asgs=$(aws cloudformation describe-stack-resources \
--stack-name "$stack_name" \
--query \
'StackResources[?ResourceType== \
`AWS::AutoScaling::AutoScalingGroup`].PhysicalResourceId' \
--output text)
for asg in $asgs
do
aws autoscaling resume-processes \
--auto-scaling-group-name "$asg"
done
}
[ $# -ne 2 ] && usage
# validates the inputs
read -r stack_name s3_bucket <<< "$@"
# deletes a key pair and deployment artifacts
delete_all_artifacts
# resumes any suspended processes in auto-scaling groups
resume_all_autoscaling_processes
# deletes the CloudFormation stack specified in the inputs
aws cloudformation delete-stack \
--stack-name "$stack_name"Designing the tests
Test cases
- a usage message is expected if incorrect inputs are passed
- for a stack with no auto-scaling groups:
- key pairs and deployment artifacts are expected to be deleted
aws cloudformation delete-stack
should be issued
- for a stack with multiple auto-scaling groups:
- a
resume-processes
command should be issued for each auto-scaling group
- a
- for a stack with no auto-scaling groups:
- Issue: it doesn’t try to handle a non-existent S3 bucket and a non-existent CloudFormation stack. So we can:
- document this as a known issue
- fixing the script to be more defensive (recommended!)
- writing tests to test for and demonstrate the known issue
- a usage message is expected if incorrect inputs are passed
Structure of the tests on
shunit2/delete_stack.sh
- the variable
$script_under_test
as mentioned above - a mocks section to replace commands that make calls to AWS
- a more general setup/teardown section
- some test cases, being the shell functions whose names start with
test*
- the final call to
shUnit2
itself
- the variable
Test cases
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# section 1 - the script under test
script_under_test=$(basename "$0")
# section 2 - the mocks
aws() {
echo "aws $*" >> commands_log
case "aws $*" in
"aws ec2 delete-key-pair --key-name mystack") true ;;
"aws s3 rm --recursive --quiet s3://mybucket/deployments/mystack") \
true ;;
"aws cloudformation describe-stack-resources \
--stack-name mystack \
--query "'StackResources[?ResourceType== \
`AWS::AutoScaling::AutoScalingGroup`].PhysicalResourceId'" \
--output text")
echo mystack-AutoScalingGroup-xxxxxxxx
;;
"aws autoscaling resume-processes \
--auto-scaling-group-name mystack-AutoScalingGroup-xxxxxxxx")
true
;;
"aws cloudformation delete-stack --stack-name mystack") true ;;
*) echo "No response for >>> aws $*" ;;
esac
}
# section 3 - other setup or teardown
tearDown() {
rm -f commands_log
rm -f expected_log
}
# section 4 - the test cases
testSimplestExample() {
. "$script_under_test" mystack mybucket
cat > expected_log <<'EOF'
aws ec2 delete-key-pair --key-name mystack
aws s3 rm --recursive --quiet s3://mybucket/deployments/mystack
aws cloudformation describe-stack-resources --stack-name mystack \
--query StackResources[?ResourceType== \
`AWS::AutoScaling::AutoScalingGroup`].PhysicalResourceId --output text
aws autoscaling resume-processes \
--auto-scaling-group-name mystack-AutoScalingGroup-xxxxxxxx
aws cloudformation delete-stack --stack-name mystack
EOF
# diff -wu ensures that during failures, a nice readable unified diff
# of “expected” compared to “actual” is seen
assertEquals "unexpected sequence of commands issued" \
"" "$(diff -wu expected_log commands_log | colordiff | DiffHighlight.pl)"
}
# section 5 - the call to shUnit2 itself
. shunit2
Bad inputs
- Test cases
1
2
3
4
5
6
7# check if the input on method call is wrong
testBadInputs() {
# STDOUT is captured using command substitution $( ... )
actual_stdout=$(. "$script_under_test" too many arguments passed)
assertTrue "unexpected response when passing bad inputs" \
"echo $actual_stdout | grep -q ^Usage"
}
No auto-scaling
Additional mocks
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18aws() {
...
# responses for motherstack
"aws ec2 delete-key-pair --key-name myotherstack") true ;;
"aws s3 rm --recursive --quiet s3://mybucket/deployments/myotherstack") \
true ;;
"aws cloudformation describe-stack-resources \
--stack-name myotherstack \
--query "'StackResources[?ResourceType== \
`AWS::AutoScaling::AutoScalingGroup`].PhysicalResourceId'" \
--output text")
## Manual tests show that this command returns an empty string for this
echo ""
;;
"aws cloudformation delete-stack --stack-name myotherstack") true ;;
}Test cases
1
2
3
4
5testNoASGs() {
. "$script_under_test" myotherstack mybucket
assertFalse "a resume-processes command was unexpectedly issued" \
"grep -q resume-processes commands_log"
}
Running the tests
- Command plus output
1
bash shunit2/test_delete_stack.sh