Entangled is a command-line tool that is meant to be used from a shell like Bash. At some point I needed to test that the final executable is actually doing what it is supposed to do. This kind of testing is also known as integration testing. To do this testing I had several options:
- Use Haskell to run the commands and test everything
- Use Python since it is a bit more flexible
- Test directly from Bash
Testing in Haskell is usually done using Hspec
together with QuickCheck
. Hspec
manages the overall testing architechture, while QuickCheck
lets you do property testing. This all works very well with functional code, but we’re in the realm of shell scripting here: setting up an environment, do mutations, check for sanity. Somehow the prospect of coding all this up in Haskell does not sound enticing.
Python would be a nice hybrid. It has unit-testing libraries available, and all the power of a generic language. In the end however, what I want to do is, have a markdown file, emulate it being written to using patch
, check if entangled shows the correct behaviour. The tests should look like a user typing in commands, working in the editor. I ended up coding this in Bash; a decision I may come to regret, but until that time, here’s how it works.
The idea is to have a set of tests each in a Bash script with the .test
extension.
«test/example.test»=
Running the script (here with || true
to prevent Jupyter from balking):
~~~ example ~~~ Setting up in /tmp/tmp.eB4F51sedF ... ✗ running on Linux?, assert-streq args: - "GNU/Linux" - "Linux" ✓ hello.txt does not exist ✓ hello.txt is created ✓ hello.txt content Cleaning up ...
Command line interface in Bash
The main script has the following interface.
usage: test/run.sh [args] where [args] can be one of: -h help: show this help -d debug: run here instead of /tmp -x break on first failure -u <unit> only run unit -c clean after local run (with -d) -v verbose entangled Available units: - example
Command line parsing in Bash is actually quite nice.
«parse-command-line»=
This loops over command line arguments and looks for any argument matching the "hdxcvu:"
description, that is, all these arguments are flags, except for u
which expects an extra parameter. The -h
flag runs the show_help
function and exits. If an option is not recognized, we show_help
and exit with error code.
The -d
flag runs unit tests in the current directory without first running setup
.
The -x
flag breaks off the script at the first test that fails.
The -u
parameter singles out a test to run.
The -v
flag runs verbose.
The -c
flag cleans current directory (after tests have been run with -d
), by running git checkout
.
«command-line-cases»+
Help message
«show-help»=
function show_help() {
echo "usage: $0 [args]"
echo
echo "where [args] can be one of:"
echo " -h help: show this help"
echo " -d debug: run here instead of /tmp"
echo " -x break on first failure"
echo " -u <unit> only run unit"
echo " -c clean after local run (with -d)"
echo " -v verbose entangled"
echo
echo "Available units:"
for t in ${DIR}/*.test; do
echo " - $(basename ${t} .test)"
done
}
Running tests
Each test is located in a file with the .test
extension. These are Bash files, but since they do not function outside the context of this testing framework, I decided to give them a different extension. If you put # vim:ft=bash
as the last line of the file, Vim will recognize it as a Bash script. The run-test()
function takes as an argument either the name of the test or the corresponding filename with the .test
extension. First setup()
is called, then the test is sourced, after wich teardown()
is called.
«run-test»=
Setup and Teardown
Each test is run in an isolated environment created in a temporary directory. We set this up using the setup()
function.
To create a temporary directory, UNIX has the mktemp
command. This command may differ slightly between Linux and Mac though, this hack solves that issue.
Then we populate the temporary directory with all the files needed to run the test. Here we just copy everything from the current directory.
To enter the directory we use pushd
.
This allows us to get back to current working directory by running popd
. The teardown()
function does exactly that, and removes the temporary directory.
The main script
The main script has to know where it is located. The following one-liner puts the name of the directory containing the script that is being run in ${DIR}
. There are other ways, but this has the advantage of also working on MacOS.
We are running all test by default. If any test fails, EXIT_CODE
has to be set to 1
.
«test/run.sh»=
# taste environment
<<get-script-dir>>
<<define-exit-code>>
# function definitions
<<reporting>>
<<assertions>>
<<setup>>
<<teardown>>
<<run-test>>
<<show-help>>
# main script
<<parse-command-line>>
if [ -z ${test_only} ]; then
for unit in "${DIR}"/*.test; do
run-test "${unit}"
done
else
if [ -f "${test_only}.test" ]; then
run-test "${test_only}"
else
echo "Could not find test: ${test_only}"
fi
fi
exit ${EXIT_CODE}
Reporting
In the case of a test succeeding, print a message with a green ✓
. Argument $1
describes the test.
If a test fails, we print a message explaining the failure with a red ✗
. Argument $1
is the name of the assertion, argument $2
the description of the test, the rest are arguments to the failed assertion.
«reporting»+
Assertions
The first argument of an assertion is always the human-readable description. The following assertions are defined.
String equality
Test if two strings are equal.
✗ Time is an illusion, assert-streq args: - "Thursday" - "Friday"
Implementation:
«assertions»=
Array equality
Tests wether the arrays in arguments $2
and $3
are equal by string comparison, for example when listing expected files. Do make sure to use sort
.
assert-arrayeq "Source contains expected files" \
"$(entangled list | sort)" "factorial.scm hello.scm"
Implementation:
«assertions»+
File existence
Tests wether a given file exists.
✗ hello.txt exists, assert-exists args: - "hello.txt" ✓ hello.txt was created ✓ hello.txt was destroyed
Implementation:
«assertions»+
The following succeeds if the given filename does not exist.
«assertions»+
Command success
To test wether the previous command returned success by calling these functions with $?
argument.
~/.local/bin/entangled
✓ Entangled executable found
The assert-return-fail
function succeeds if the command failed (exit code other than 0).
«assertions»+
The assert-return-success
function succeeds if the command return success (exit code 0).