How do you test your bash scripts? Do you test them at all?
Bash scripting is one of the handiest tools every developer has to automate tasks, ease the everyday activities, help to reduce the toil. But automation tools can actually increase the toil if are not treated the same way as code. Bash scripts can get out of date and stop working, became hard to maintain, and increase your technical debt. You need to check them in version control, write tests, and use CI.
In this article, we’ll go through how you can improve the quality of your automation scripts writing tests for Bash.
Bach is a testing framework for Bash that provides the possibility to write unit tests for your Bash scripts. Bach makes any command in the PATH an external dependency of the tested Bash script so that no command will be actually executed but will run as “dry run”. In this way, you will be able to test the logic of the script and not the commands themself. Bach mocks all commands by default and provides a set of APIs for executing real commands if necessary.
To install Bach Testing Framework download bach.sh to your project, use the source
command to import bach.sh
.
For example:
source path/to/bach.sh
In Bach, we can test what the Bash script will actually execute.
Every test case in Bach is made of two functions: one for running tests and the other for asserting.
When you run your tests Bach will execute the two functions separately and will compare the sequence of commands executed by both functions. Every testing function must start with the name test-, the asserting function must end with -assert.
Let’s see some practical examples of how to write test cases.
#!/usr/bin/env bash
set -euo pipefail
source bach.sh
To enable the Bach Testing Framework the first thing to do is to source the bash.sh.
test-rm-your-dot-git() {
@mock find ~ -type d -name .git === @stdout ~/src/your-awesome-project/.git \
~/src/code/.git
find ~ -type d -name .git | xargs -- rm -rf
}
test-rm-your-dot-git-assert() {
rm -rf ~/src/your-awesome-project/.git ~/src/code/.git
}
The first script we test is a command to find all the .git folders and remove them. We can test it by mocking the find
command with those parameters to output two directories.
test-mock-script-with-custom-complex-action() {
@mock ./path/to/script <<\SCRIPT
if [[ "$1" == foo ]]; then
@echo bar
else
@echo anything
fi
SCRIPT
./path/to/script foo
./path/to/script something
}
test-mock-script-with-custom-complex-action-assert() {
bar
anything
}
In the second script, we use Bach to test a script that returns a different output based on the input. In this case, we mock the script using @mock ./path/to/script
and then we define the script behavior.
test-bach-framework-mock-commands() {
@mock find . -name fn === @stdout file1 file2
ls $(find . -name fn)
@mock ls file1 file2 === @stdout file2 file1
ls $(find . -name fn) | xargs -n1 -- do-something
@mock ls === @stdout foo bar foobar
ls | xargs -n2 -- bash -c 'do-something ${@}' -s
}
test-bach-framework-mock-commands-assert() {
ls file1 file2
do-something file2
do-something file1
bash -c 'do-something ${@}' -s foo bar
bash -c 'do-something ${@}' -s foobar
}
In this script, you can see how to perform some complex operations like mocking a command and execute it in a $(...)
expression and using pipes.
test-bach-framework-set--e-should-work() {
set -e
do-this
builtin false
should-not-do-this
}
test-bach-framework-set--e-should-work-assert() {
do-this
@fail
}
Here we test the behavior of set -e
so we make the script fail with builtin false
and we test the failure using @fail
.
test-no-double-quote-star() {
@touch bar1 bar2 bar3 "bar*"
function cleanup() {
rm -rf $1
}
# We want to remove the file "bar*", not the others
cleanup "bar*"
}
test-no-double-quote-star-assert() {
# Without double quotes, all bar files are removed!
rm -rf "bar*" bar1 bar2 bar3
}
test-double-quote-star() {
@touch bar1 bar2 bar3 "bar*"
function cleanup() {
rm -rf "$1"
}
# We want to remove the file "bar*", not the others
cleanup "bar*"
}
test-double-quote-star-assert() {
# Yes, with double quotes, only the file "bar*" is removed
rm -rf "bar*"
}
Here we define a function
that takes an argument and removes it. In the first example, we don’t use double quotes in the function, this will remove all the files starting with bar-. In the second example, the function is using double quotes so only the file called bar* is removed.
You can write all your test cases in a single .sh
file remembering to add the source bach.sh
in it. At this point to run the tests you just need to execute the .sh
file.
In this post, we’ve seen how to write unit tests for your Bash scripts to improve the quality and reliability of your automation.
Reach me on Twitter @gasparevitta and let me know your thoughts!