Unit testing a Terraform user_data script with shUnit2
by Alex Harvey
In this post, which could be read as part II to an earlier post, I document a method for unit testing a Terraform user_data script that is broken into functions.
- Why test
- About the example code
- What are we testing
- Refactor into functions
- A note about the guard clause
- Benefits of refactoring
- Writing the unit tests
- Testing the tests
- Summary
- See also
Why test
Those unfamiliar with the practice of unit testing Bash shell scripts like UserData scripts are often confused about what we would test and why we would bother to do this. So let me set out here some of the reasons to unit test Bash UserData scripts.
Use case | UserData example |
---|---|
Safely refactor code | Minor style improvements to a Bash UserData script should not require expensive end-to-end tests. |
Quickly test complex Bash one-liners or complex logic | Some common examples include testing jq , sed , and awk one-liners. |
Unit tests often force best practices on the code author | Badly-written Bash code is often not testable. Unit tests force this code to be refactored. |
Unit tests provide a layer of code-as-documentation that otherwise would not exist | If a jq command is unreadable, for example, the tests for this will assist the reader understand what it does. |
About the example code
Example script
I begin with a typical-looking Bash UserData script that has a number of problems and is untestable in its initial form:
#!/usr/bin/env bash
echo "\
127.0.0.1 localhost localhost.localdomain $(hostname)" \
>> /etc/hosts
# update system
yum -y update
# install deps
yum -y install aws-cli awslogs jq
# configure cloudwatch
read -r instance_id region <<< "$(
curl -s http://169.254.169.254/latest/dynamic/instance-identity/document \
| jq -r '[.instanceId, .region] | @tsv'
)"
cat > /etc/awslogs/awslogs.conf <<EOF
[general]
state_file = /var/lib/awslogs/agent-state
[/var/log/dmesg]
file = /var/log/dmesg
log_stream_name = $instance_id/dmesg
log_group_name = ${gitlab_runner_log_group_name}
initial_position = start_of_file
[/var/log/messages]
file = /var/log/messages
log_stream_name = $instance_id/messages
log_group_name = ${gitlab_runner_log_group_name}
datetime_format = %b %d %H:%M:%S
initial_position = start_of_file
[/var/log/user-data.log]
file = /var/log/user-data.log
log_stream_name = $instance_id/user-data
log_group_name = ${gitlab_runner_log_group_name}
initial_position = start_of_file
EOF
sed -i '
s/region = us-east-1/region = '"$region"'/
' /etc/awslogs/awscli.conf
service awslogs start
chkconfig awslogs on
# generate config.toml
mkdir -p /etc/gitlab-runner
cat > /etc/gitlab-runner/config.toml <<EOF
${runners_config}
EOF
# install gitlab runner
curl -L \
https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh | bash
yum -y install gitlab-runner-"${gitlab_runner_version}"
curl --fail --retry 6 -L \
https://github.com/docker/machine/releases/download/v"${docker_machine_version}"/docker-machine-"$(uname -s)"-"$(uname -m)" \
> /usr/local/bin/docker-machine
chmod +x /usr/local/bin/docker-machine
ln -s /usr/local/bin/docker-machine /usr/bin/docker-machine
# Create a dummy machine so that the cert is generated properly
# See: https://gitlab.com/gitlab-org/gitlab-runner/issues/3676
docker-machine create --driver none --url localhost dummy-machine
# register runner
token=$(aws ssm get-parameters --names "${runners_ssm_token_key}" \
--with-decryption --region "${aws_region}" | jq -r '.Parameters[0].Value')
if [ "$token" == "null" ] ; then
response=$(
curl -X POST -L "${runners_url}/api/v4/runners" \
-F "token=${gitlab_runner_registration_token}" \
-F "description=${gitlab_runner_description}" \
-F "locked=${gitlab_runner_locked_to_project}" \
-F "maximum_timeout=${gitlab_runner_maximum_timeout}" \
-F "access_level=${gitlab_runner_access_level}")
token=$(jq -r .token <<< "$response")
if [ "$token" == "null" ] ; then
echo "Received the following error:"
echo "$response"
return
fi
aws ssm put-parameter --overwrite --type SecureString --name \
"${runners_ssm_token_key}" --value "$token" --region "${aws_region}"
fi
sed -i 's/##TOKEN##/'"$token"'/' /etc/gitlab-runner/config.toml
# start gitlab runner
service gitlab-runner restart
chkconfig gitlab-runner on
# vim: set ft=sh:
What it does
This is a script for an EC2 UserData script that installs a Gitlab Runner.
Terraform declaration
Because this is a Terraform UserData script there will be a declaration in the Terraform code that looks like this for it:
data "template_file" "user_data" {
template = file("${path.module}/template/user-data.sh.tpl")
vars = {
aws_region = var.aws_region
docker_machine_version = local.docker_machine_version
gitlab_runner_description = var.gitlab_runner_registration_config["description"]
gitlab_runner_access_level = var.gitlab_runner_registration_config["access_level"]
gitlab_runner_locked_to_project = var.gitlab_runner_registration_config["locked_to_project"]
gitlab_runner_maximum_timeout = var.gitlab_runner_registration_config["maximum_timeout"]
gitlab_runner_registration_token = var.gitlab_runner_registration_config["registration_token"]
gitlab_runner_version = local.gitlab_runner_version
gitlab_runner_log_group_name = local.gitlab_runner_log_group_name
runners_config = data.template_file.runners.rendered
runners_ssm_token_key = local.runners_ssm_token_key
runners_url = var.runners_url
}
}
Note about variable interpolation
Terraform interprets a notation ${ ... }
as a variable to be interpolated. Thus, ${aws_region}
will be interpolated as something like ap-southeast-2
in the generated Bash script. Of course, Bash also understands ${aws_region}
to mean variable expansion, although it fortunately also allows $aws_region
. My proposal for Terraform UserData scripts is therefore to never use the Bash ${ ... }
notation.
Note that Martin Atkins at HashiCorp has recommended a different approach:
Sequences that look like interpolation sequences can be escaped by doubling the quotes:
username=$${USERNAME:-deploy}
To reduce the impact of such conflicts, I usually recommend splitting the logic and the variables into two separate files. The template would then just be a wrapper around the main script, which is uploaded verbatim without any template processing.
The problem with Martin’s approach is that it becomes a bit unreadable and also means the testing method I am herein proposing will not work.
What are we testing
As is often the case with such UserData scripts, a lot of it does not really need to be unit-tested. Consider a line:
yum -y update
How could I “unit test” that? I would hope that the maintainers of the yum system do have good testing practices but from my end, the only testing I could do would be at a system test level. Even then I couldn’t really test much. Those who dare to run yum -y update must hope that upstream yum repos are working!
If I did want to cover this line in a unit test, I could replace external yum command with a stub like so:
yum() { : ; }
This would allow the yum command to then be executed in my unit test environment and do nothing. Still, I would not really be “testing” anything if I did this.
What I really want to do is test all those curl
, jq
and sed
commands. But if I wrote tests in the script’s current format, I would have a huge amount of setup for the sake of only a small amount of testing. If your setup leads to significantly more code than your tests, I consider it to be a bit of a testing anti-pattern.
So we must begin by refactoring this script into functions. That will make it both more readable, a better script, and testable.
Refactor into functions
After refactoring the script into functions I end up with this:
#!/usr/bin/env bash
awslogs_conf='/etc/awslogs/awslogs.conf'
awscli_conf='/etc/awslogs/awscli.conf'
config_toml='/etc/gitlab-runner/config.toml'
update_hosts_file() {
echo "\
127.0.0.1 localhost localhost.localdomain $(hostname)" \
>> /etc/hosts
}
update_system() {
yum -y update
}
install_deps() {
yum -y install aws-cli awslogs jq
}
make_awslogs_conf() {
local instance_id="$1"
cat <<EOF
[general]
state_file = /var/lib/awslogs/agent-state
[/var/log/dmesg]
file = /var/log/dmesg
log_stream_name = $instance_id/dmesg
log_group_name = ${gitlab_runner_log_group_name}
initial_position = start_of_file
[/var/log/messages]
file = /var/log/messages
log_stream_name = $instance_id/messages
log_group_name = ${gitlab_runner_log_group_name}
datetime_format = %b %d %H:%M:%S
initial_position = start_of_file
[/var/log/user-data.log]
file = /var/log/user-data.log
log_stream_name = $instance_id/user-data
log_group_name = ${gitlab_runner_log_group_name}
initial_position = start_of_file
EOF
}
configure_cloudwatch() {
local instance_id region
read -r instance_id region <<< "$(
curl -s http://169.254.169.254/latest/dynamic/instance-identity/document \
| jq -r '[.instanceId, .region] | @tsv'
)"
make_awslogs_conf "$instance_id" > "$awslogs_conf"
sed -i '
s/region = us-east-1/region = '"$region"'/
' "$awscli_conf"
service awslogs start
chkconfig awslogs on
}
generate_config_toml() {
mkdir -p /etc/gitlab-runner
cat > "$config_toml" <<EOF
${runners_config}
EOF
}
install_gitlab_runner() {
curl -L \
https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh | bash
yum -y install gitlab-runner-"${gitlab_runner_version}"
curl --fail --retry 6 -L \
https://github.com/docker/machine/releases/download/v"${docker_machine_version}"/docker-machine-"$(uname -s)"-"$(uname -m)" > /tmp/docker-machine
chmod +x /tmp/docker-machine
cp /tmp/docker-machine /usr/local/bin/docker-machine
ln -s /usr/local/bin/docker-machine /usr/bin/docker-machine
# Create a dummy machine so that the cert is generated properly
# See: https://gitlab.com/gitlab-org/gitlab-runner/issues/3676
docker-machine create --driver none --url localhost dummy-machine
}
register_runner() {
token_first_try=$(aws ssm get-parameters --names "${runners_ssm_token_key}" \
--with-decryption --region "${aws_region}" | jq -r '.Parameters[0].Value')
if [ "$token_first_try" == "null" ] ; then
response=$(curl -X POST -L \
"${runners_url}/api/v4/runners" \
-F "token=${gitlab_runner_registration_token}" \
-F "description=${gitlab_runner_description}" \
-F "locked=${gitlab_runner_locked_to_project}" \
-F "maximum_timeout=${gitlab_runner_maximum_timeout}" \
-F "access_level=${gitlab_runner_access_level}")
token_second_try=$(jq -r .token <<< "$response")
if [ "$token_second_try" == "null" ] ; then
echo "Received the following error:"
echo "$response"
return
fi
aws ssm put-parameter --overwrite --type 'SecureString' --name \
"${runners_ssm_token_key}" --value "$token" --region "${aws_region}"
fi
sed -i 's/##TOKEN##/'"$token"'/' "$config_toml"
}
start_gitlab_runner() {
service gitlab-runner restart
chkconfig gitlab-runner on
}
main() {
update_hosts_file
update_system
install_deps
configure_cloudwatch
generate_config_toml
install_gitlab_runner
register_runner
start_gitlab_runner
}
if [ "$0" == "$BASH_SOURCE" ] ; then
main
fi
# vim: set ft=sh:
A note about the guard clause
Note the lines at the end there:
if [ "$0" == "$BASH_SOURCE" ] ; then
main
fi
These lines are required so that I can source the script in the context of the unit test environment without any code executing. That is because when a script is executed, $0
is set to the name of the script whereas when a script is sourced into the running shell, $0
is set to bash
.
Be aware that those lines are normally written as:
if [ "$0" == "${BASH_SOURCE[0]}" ] ; then
main
fi
I can’t use that notation in a Terraform user_data script because Terraform would try to interpolate there and our generated script would be broken.
See also this Stack Overflow answer.
Benefits of refactoring
This code is now testable. I can test the configure_cloudwatch
and register_runner
functions and ignore all the rest of the code. All those redundant comments like # start gitlab runner
have been turned into the names of functions making the code self-documenting. Sometimes people will tell you, “comments are bugs”. This refactoring exercise helps reveal why some comments really are “bugs”. I also have a main
function that summarises the logic of the UserData script at a high-level. This is a good piece of code-as-documentation that didn’t otherwise exist.
So the discipline of unit testing has already led to a better piece of code - and I haven’t written any tests yet!
Writing the unit tests
Installing shunit2
Because shUnit2 is still not released very often, it is, at the time of writing, necessary to get shunit2 from the master branch of the Git project like so:
▶ curl \
https://github.com/kward/shunit2/blob/c47d32d6af2998e94bbb96d58a77e519b2369d76/shunit2 \
/usr/local/bin/shunit2
This is a version that I know works and has some patches e.g. for coloured output not yet in the released version.
Project structure
I assume you will have a project structure like this:
▶ tree
├── main.tf
├── shunit2
│ └── test_user_data.sh
└── template
└── user-data.sh.tpl
Test boilerplate
I started with a file shunit2/test_user_data.sh
like this:
#!/usr/bin/env bash
if [ "$(uname -s)" == "Darwin" ] ; then
if [ ! -x /usr/local/bin/gsed ] ; then
echo "On Mac OS X you need to install gnu-sed:"
echo "$ brew install gnu-sed"
exit 1
fi
shopt -s expand_aliases
alias sed='/usr/local/bin/gsed'
fi
script_under_test='template/user-data.sh.tpl'
setUp() {
. "$script_under_test"
}
testConfigureCloudwatch() {
true
}
. shunit2
Most of that is self-explanatory. If you’re running this on Linux instead of Mac OS X you may not need to deal with gsed.
testConfigureCloudwatch
function-under-test
So the configure_cloudwatch
function looks like this:
awslogs_conf='/etc/awslogs/awslogs.conf'
awscli_conf='/etc/awslogs/awscli.conf'
configure_cloudwatch() {
local instance_id region
read -r instance_id region <<< "$(
curl -s http://169.254.169.254/latest/dynamic/instance-identity/document \
| jq -r '[.instanceId, .region] | @tsv'
)"
make_awslogs_conf "$instance_id" > "$awslogs_conf"
sed -i '
s/region = us-east-1/region = '"$region"'/
' "$awscli_conf"
service awslogs start
chkconfig awslogs on
}
I want to test the jq
command and the sed
command. I am also happy to indirectly test the make_awslogs_conf
function. Some purists might say this is an anti-pattern and my tests should test these two functions separately. Meh. That would lead to more lines of test code for no additional benefit. Knowing the rules helps to know when to break them I suppose.
mocking the curl command
The first thing I want to test is the read
then curl
then jq
contruction. Does that actually work? What does it do? So I log onto an EC2 instance and get myself some real output for the curl
command in my script:
▶ curl -s http://169.254.169.254/latest/dynamic/instance-identity/document
{
"accountId" : "111111111111",
"architecture" : "x86_64",
"availabilityZone" : "ap-southeast-2a",
"billingProducts" : null,
"devpayProductCodes" : null,
"marketplaceProductCodes" : null,
"imageId" : "ami-08589eca6dcc9b39c",
"instanceId" : "i-04a8628dca6b55a60",
"instanceType" : "t2.micro",
"kernelId" : null,
"pendingTime" : "2020-02-02T05:38:21Z",
"privateIp" : "172.31.14.8",
"ramdiskId" : null,
"region" : "ap-southeast-2",
"version" : "2017-09-30"
}
Now I could hard-code all of that data in my test, although I can see that since I filter the output through jq -r
I actually only really care about the two keys instanceId
and region
. To keep my tests concise, I am going to set up the following mock:
curl() { echo '{"instanceId":"i-11111111","region":"ap-southeast-2"}' ; }
Mocking the service and chkconfig commands
Next I’ll need to create a fake service
and chkconfig
command. Because they are incidental to the logic I am testing, I can replace them with stubs that don’t do anything:
service() { : ; }
chkconfig() { : ; }
Mocking the awscli_conf and awslogs_conf files
Notice how I replaced the /etc/awslogs/awslogs.conf
and /etc/awslogs/awscli.conf
files with variables. If I had not done this, I would have testing problems because I probably will not have write access in my test environment to the /etc
directory. And I certainly don’t want my tests to change stuff in there! I could run the tests inside a jail or a Docker container, but that seems like way too much trouble.
By replacing these two file paths with variables, I can read them from elsewhere in the context of my tests, and I can replace them with fakes in my tests:
awslogs_conf='./test_awslogs.conf'
awscli_conf='./test_awscli.conf'
cat > "$awscli_conf" <<EOF
foo bar foo bar
region = us-east-1
baz qux baz qux
EOF
Putting this all together
I can then have my first test case as follows:
testConfigureCloudwatch() {
curl() { echo '{"instanceId":"i-11111111","region":"ap-southeast-2"}' ; }
service() { : ; }
chkconfig() { : ; }
awslogs_conf='./test_awslogs.conf'
awscli_conf='./test_awscli.conf'
cat > "$awscli_conf" <<EOF
foo bar foo bar
region = us-east-1
baz qux baz qux
EOF
configure_cloudwatch
assertTrue "$awslogs_conf does not contain instance_id" "grep -q i-11111111 $awslogs_conf"
assertTrue "$awscli_conf does not contain region" "grep -q ap-southeast-2 $awscli_conf"
rm -f "$awslogs_conf" "$awscli_conf"
}
Here I have tested that:
- My
read
,curl
andjq
construction has successfully read in the instance_id and region from the curl command. - The
sed
command has correct replaced the region in$awscli_conf
. - The
$awslogs_conf
file contains the instance ID.
I could probably be a bit more verbose and pedantic about it but this is enough to convince myself that this function “works”.
testing the register_runner function
function-under-test
The other function to test is register_runner
:
register_runner() {
token_first_try=$(aws ssm get-parameters --names "${runners_ssm_token_key}" \
--with-decryption --region "${aws_region}" | jq -r '.Parameters[0].Value')
if [ "$token_first_try" == "null" ] ; then
response=$(curl -X POST -L \
"${runners_url}/api/v4/runners" \
-F "token=${gitlab_runner_registration_token}" \
-F "description=${gitlab_runner_description}" \
-F "locked=${gitlab_runner_locked_to_project}" \
-F "maximum_timeout=${gitlab_runner_maximum_timeout}" \
-F "access_level=${gitlab_runner_access_level}")
token_second_try=$(jq -r .token <<< "$response")
if [ "$token_second_try" == "null" ] ; then
echo "Received the following error:"
echo "$response"
return
fi
aws ssm put-parameter --overwrite --type 'SecureString' --name \
"${runners_ssm_token_key}" --value "$token" --region "${aws_region}"
fi
sed -i 's/##TOKEN##/'"$token"'/' "$config_toml"
}
This one is more complex as it contains conditional logic and therefore multiple logical pathways. Those paths are reached depending on the value returned to $token
. In this first test case, I let the value returned to $token
be null
.
white box testing
If I was to write out all test cases, I would start by drawing up all the pathways through this code:
$token_first_try
==null
,$token_second_try
!=null
$token_first_try
==null
,$token_second_try
==null
=> error - possibly the curl command can fail in more than one way that could lead to us getting here.$token_first_try
!=null
.
I am not going to cover all of these in the post but I would normally cover them all in the code.
test case 1
This time my first test looks like this:
testRegisterRunnerTokenNull() {
aws() {
case "${FUNCNAME[0]} $*" in
"aws ssm get-parameters --names $runners_ssm_token_key --with-decryption --region $aws_region")
echo '{"InvalidParameters":["'"$runners_ssm_token_key"'"],"Parameters":[]}' ;;
"aws ssm put-parameter --overwrite --type SecureString --name $runners_ssm_token_key --value $token --region $aws_region")
echo '{"Version":"1"}' ;;
esac
}
curl() { echo '{"token":"ANOTHERSECRETTOKEN"}' ; }
config_toml='./test_config.toml'
cat > "$config_toml" <<EOF
foo bar foo bar
this line has ##TOKEN## in it
baz qux baz qux
EOF
runners_ssm_token_key='/mykey'
aws_region='ap-southeast-2'
runners_url='https://gitlab.com'
gitlab_runner_registration_token='XXXXXXXX'
gitlab_runner_description='my runner'
gitlab_runner_locked_to_project='true'
gitlab_runner_maximum_timeout='10'
gitlab_runner_access_level='debug'
gitlab_runner_log_group_name='gitlab-runner-log-group'
register_runner
assertTrue "$config_toml does not have secret token in it" "grep -q ANOTHERSECRETTOKEN $config_toml"
rm -f "$config_toml"
}
The only real conceptual difference between this test and the previous one is that my aws
mock behaves differently depending on what the arguments passed to aws
are. (See my earlier post Testing AWS CLI scripts in shunit2 for more info.)
test case 2
In the second test case, I allow the curl
command to receive a difference response from the Gitlab API:
curl() { echo '{"message":{"tags_list":["can not be empty when runner is not allowed to pick untagged jobs"]}}' ; }
Obviously, I could only learn that it would respond in such a way from either experimentation or the API documentation etc. In my case, it was experimentation.
The full test is:
testRegisterRunnerWithError() {
aws() {
case "${FUNCNAME[0]} $*" in
"aws ssm get-parameters --names $runners_ssm_token_key --with-decryption --region $aws_region")
echo '{"InvalidParameters":["'"$runners_ssm_token_key"'"],"Parameters":[]}' ;;
esac
}
curl() { echo '{"message":{"tags_list":["can not be empty when runner is not allowed to pick untagged jobs"]}}' ; }
config_toml='./test_config.toml'
cat > "$config_toml" <<EOF
foo bar foo bar
this line has ##TOKEN## in it
baz qux baz qux
EOF
runners_ssm_token_key='/mykey'
aws_region='ap-southeast-2'
runners_url='https://gitlab.com'
gitlab_runner_registration_token='XXXXXXXX'
gitlab_runner_description='my runner'
gitlab_runner_locked_to_project='true'
gitlab_runner_maximum_timeout='10'
gitlab_runner_access_level='debug'
gitlab_runner_log_group_name='gitlab-runner-log-group'
register_runner
assertTrue "$config_toml has been unexpectedly edited" "grep -q '##TOKEN##' $config_toml"
rm -f "$config_toml"
}
A quick note about these lines:
runners_ssm_token_key='/mykey'
aws_region='ap-southeast-2'
runners_url='https://gitlab.com'
gitlab_runner_registration_token='XXXXXXXX'
gitlab_runner_description='my runner'
gitlab_runner_locked_to_project='true'
gitlab_runner_maximum_timeout='10'
gitlab_runner_access_level='debug'
gitlab_runner_log_group_name='gitlab-runner-log-group'
These are all variables that are expected to be interpolated by Terraform itself in the generated Bash UserData script. But, because Terraform’s notation is valid Bash code too, I can set global Bash variables for the values I expected Terraform to place there, and still know I’m testing the same code.
This second test, by the way, is really just documenting a known way that this automation can fail. If it does fail in this way, the user of the Terraform module will be thankful for this test that explains what went wrong.
Running the tests
Another advantage of the tests is the user, on running them, sees what the expected output is from this UserData script:
▶ bash shunit2/test_user_data.sh
testConfigureCloudwatch
testRegisterRunnerTokenNull
{"Version":"1"}
testRegisterRunnerWithError
Received the following error:
{"message":{"tags_list":["can not be empty when runner is not allowed to pick untagged jobs"]}}
Ran 3 tests.
OK
Testing the tests
Ok. Let’s prove that these tests add value. I want to refactor something. There is something I don’t like about this code here:
read -r instance_id region <<< "$(
curl -s http://169.254.169.254/latest/dynamic/instance-identity/document \
| jq -r '[.instanceId, .region] | @tsv'
)"
The @tsv
is a bit confusing. I find join()
to be more readable. So I want to refactor this as:
read -r instance_id region <<< "$(
curl -s http://169.254.169.254/latest/dynamic/instance-identity/document \
| jq -r '[.instanceId, .region] | join(" ")'
)"
So I make that change:
diff --git a/template/user-data.sh.tpl b/template/user-data.sh.tpl
index 8507c6e..6f6d529 100644
--- a/template/user-data.sh.tpl
+++ b/template/user-data.sh.tpl
@@ -23,7 +23,7 @@ configure_cloudwatch() {
read -r instance_id region <<< "$(
curl -s http://169.254.169.254/latest/dynamic/instance-identity/document \
- | jq -r '[.instanceId, .region] | @tsv'
+ | jq -r '[.instanceId, .region] | join(" ")'
)"
cat > "$awslogs_conf" <<EOF
And run the tests again:
▶ bash shunit2/test_user_data.sh
testConfigureCloudwatch
testRegisterRunnerTokenNull
{"Version":"1"}
testRegisterRunnerWithError
Received the following error:
{"message":{"tags_list":["can not be empty when runner is not allowed to pick untagged jobs"]}}
Ran 3 tests.
OK
Great. My change is good and I can commit that and not worry about expensive end-to-end testing.
Summary
So that’s my unit testing method for Terraform user_data scripts. In this post, I have documented a method of testing these scripts using shUnit2. The post could be read as a second part to my earlier post, Unit testing a Bash script using shUnit2, in so far as it shows how to do unit testing in Bash where the units are functions instead of scripts. I also have shown some Terraform-specific tricks for best practices with Bash user_data
scripts, and covered a bit of theory of unit testing Bash scripts in general.
See also
My earlier posts on shUnit2:
- Jul 7, 2017, Unit Testing a Bash Script with shUnit2.
- Sep 7, 2018, Testing AWS CLI scripts in shUnit2.
And see also my Placebo library on GitHub, Placebo for Bash.
tags: terraform - shunit2 - bash