Unit tests in bash

I yesterday ported the tear-down part of MicroFW from Python back to Bash. (It was just unhappy in Python.) In doing so, I tested it by uploading it to my server, running it and seeing what happens. This is obviously unsatisfactory, but since the script runs a bunch of iptables calls and the like, how am I supposed to test it? This integrates pretty deeply into the system, no way of mocking that, right?

Well, yes way: You just need to shamelessly abuse the function keyword...

Mocking system commands

As you may or may not know, bash allows the definition of functions:

function something() {
    echo "I do stuff!"
}

These functions can then be invoked like any other command, at least from inside the script:

function something() {
    echo "I do stuff!"
}

something
# prints I do stuff!

So if I have a script that heavily invokes iptables, like so:

iptables -t filter -D FORWARD -j MFWFORWARD

If I now were to define a function named iptables, that function would get called instead of the actual iptables command, right?

function iptables () {
    echo "iptables" "$@" >> "$TEMPDIR/out.txt"
}

Right! So now I can just run my original script by sourcing it, and whenever it tries to run iptables, it will instead just append the invocation to a logfile but not actually do anything. I can then grep through that logfile to see if the invocation I'm looking for is actually there. Pretty nice.

Did you know source can take args?

Allright, so after we defined the mock functions, let's source the original script:

source src/microfw.sh
# prints "Need a command, see --help" and exits

Ahh damn, it checks $1 for a command that tells it what to do. How do we pass one? We can't just define $1 with some value:

1=tear_down
source src/microfw.sh

This just results in 1=tear_down: command not found. But turns out we can pass an argument to source itself:

source src/microfw.sh tear_down

This will work! However, I didn't end up using it because I needed a RUNNING_IN_CI variable eventually anyway, so I just added code so I can define the command via a variable directly.

I can haz test runner?

Now, for the whole thing to feel really unittesty, we need a test runner that tells us which tests have succeeeded and which ones have failed. How can we achieve that? In JavaScript, I'd probably do something along the lines of:

function test_something() {
    // Whatever unittest framework syntax necessary to assert something
}

function run_test(test_fn) {
    if (test_fn()) {
        console.log(test_fn.name + " ... ok");
    } else {
        console.log(test_fn.name + " ... failed");
    }
}

run_test(test_something);

This is hella crude, but you get the idea: Define the test as a function, pass it to a test runner, and the test runner will then take care of telling you that it ran the test and what the result was. But can we actually pass a function to another function, to have it called by that function instead of calling it ourselves? Turns out yes, we can:

function test_something() {
    echo "Running a test"
}

function run_test() {
    FUNC="$1"
    $FUNC
}

run_test test_something
# prints "Running a test"

See how I use a string variable in the place of a command there, and it just works? Coming from a more high-level language this feels really, really wrong - I'm passing a string for-crying-out-loud, how can I just treat it like a function!? - but that's how it is. So adding just a tiny bit more ceremony like, you know, an if statement that checks the result and some result printery, we end up with this:

function test_something() {
    echo "Running a test"
}

function run_test() {
    FUNC="$1"
    echo -n "$FUNC... "
    if $FUNC; then
        echo "ok"
    else
        echo "failed"
        exit 1
    fi
}

run_test test_something
# prints "test_something... ok"

And nicely enough, since set -e causes functions to return early when commands fail, I can just add a bunch of tests into my test functions and it'll just work:

function test_no_state() {
    tear_down
    [ ! -e "$TEMPDIR/out.txt" ]
}

This function runs the code and checks that no out.txt file has been created by it, effectively testing that the code didn't do anything. I don't need to add any ceremony to the test_no_state function to check the result of that check, because if it fails, set -e tells bash to just return 1 from the function and run_test will declare the test failed!

Conclusion

So, Unit tests in bash are possible. I'm quite flabbergasted, but oh well :D