UP | HOME

Bash Testing Tips and Tricks

Table of Contents

When we're in the business of messing with files and installing things on unix systems, bash can be a really useful programming language to have around.

I like to be able to test my programs. Below I'm going to talk about the technology I commonly use for testing bash programs. I'll cover:

Helpful Structure

As a piece of system automation grows, I'll often find clusters of three files start to appear:

  • -rwxrwxr-x util
  • -rw-rw-r-- util_libs.bash
  • -rw-rw-r-- util_libs_test.bash

The util file is executable. It is the script I call when I want to use this utility. The logic for this utility is defined in functions which live in util_libs.bash. The tests for those functions live in util_libs_test.bash. I make a point of having no top-level commands in util_libs.bash. It is intended to be loaded with source util_libs.bash, and contains only function definitions.

I like to use basht for my tests, and can run them all with basht *_test.bash. basht is a pretty minimal testing tool. The basht README will tell you how to write and run a basic test. That's all it gives you, and that's all we need from it. The rest we can get from bash and unix.

Test Doubles and Dynamic Scope

I sometimes like to be able to use (something like) test doubles in my tests. Of course, bash isn't object-oriented, so the metaphor is a little streched… But it turns out that we can get quite close to what I think of as the spirit of test-doubles, just using pure bash, with no need for frameworks.

If you haven't come across test doubles before, a good place to start reading is Martin Fowler's definitions. Sorts of test doubles include dummies, stubs, spies, fakes, and mocks. In any case, the rough idea will hopefully become clear enough in the stuff below.

The key idea in almost everything below is to use the dynamic scoping of bash functions and variables to inject hand-written doubles. These tricks work easily in bash, elisp, powershell and so on. They are much harder to make work in most modern lexically scoped languages.

(It's probably worth noting that I'm not arguing that dynamic scoping is a good thing. I'm just taking my advantages where I can get them.)

A Basic Stub

Suppose we have a basic basht test file that looks something like this:

source ./file_that_contains_functions_we_want_to_test.bash

T_some_test() {
    if [[ "$(some_function_we_want_to_test)" != "some output we expect to see" ]]
    then
        $T_fail "I expected to see something different from what I saw"
        return
    fi
}

But what if some_function_we_want_to_test is quite big, and we want to mock out some part of it?

Let us suppose that part of ./file_that_contains_functions_we_want_to_test.bash looks like this:

get_a_bunch_of_data() {
    ...
}
some_function_we_want_to_test() {
    local mydata
    mydata="$(get_a_bunch_of_data)"
    if somethingsomething "$mydata"
    then
        otherthings "$mydata"
        ...
    fi
}

If we just want to test the logic in some_function_we_want_to_test, we can provide a stub of get_a_bunch_of_data in our test file like so:

source ./file_that_contains_functions_we_want_to_test.bash

get_a_bunch_of_data() {
    echo "some fake data"
}
T_some_test() {
    if [[ "$(some_function_we_want_to_test)" != "some output we expect to see" ]]
    then
        $T_fail "I expected to see something different from what I saw"
        return
    fi
}

Notice that we source the file containing the real definition of get_a_bunch_of_data before we declare the stub version of get_a_bunch_of_data. Because bash is dynamically scoped, the later declaration of the function overwrites the earlier one. By the time the code under test is called, our stub function is in place, and is used by the code we're testing.

Tiny Variant 1: Injecting Commands

Now let's imagine that ./file_that_contains_functions_we_want_to_test.bash is a bit different:

some_function_we_want_to_test() {
    local mydata
    mydata="$(curl http://some.url.com/some/page)"
    if somethingsomething "$mydata"
    then
        otherthings "$mydata"
        ...
    fi
}

We've taken away the handy mockable-function get_a_bunch_of_data and replaced it with curl, which is a binary not a function.

Fortunately, in bash, functions take precedence over binaries!

So we can do exactly the same testing trick like this:

source ./file_that_contains_functions_we_want_to_test.bash

curl() {
    echo "some fake data"
}

T_some_test() {
    if [[ "$(some_function_we_want_to_test)" != "some output we expect to see" ]]
    then
        $T_fail "I expected to see something different from what I saw"
        return
    fi
}

Tiny Variant 1: Working with Scripts

All the stuff we did above only works because the function under test has access to the functions we define in our test code. But what if the thing we want to test isn't a function in a bash library but is a bash script?

Let us suppose that we have a bash script called script_to_test. It contains the following:

#!/usr/bin/env bash

mydata="$(curl http://some.url.com/some/page)"
if somethingsomething "$mydata"
then
  otherthings "$mydata"
  ...
fi
...

We'd like to be able to do the same trick, but this won't work:

curl() {
  echo "some fake data"
}

T_completely_broken_test() {
  if [[ "$(./script_to_test)" != "some output we expect to see" ]]
  then
    $T_fail "I expected to see something different from what I saw"
    return
  fi
}

!!! That test will fail to inject our stub curl into the script we want to test. The real curl will be used instead, and will make the network request we wanted to avoid. !!!

Fortunately, we can fix this problem with one line:

curl() {
  echo "some fake data"
}

T_fixed_test() {
  export -f curl   # <-------------- totally vital fix
  if [[ "$(./script_to_test)" != "some output we expect to see" ]]
  then
    $T_fail "I expected to see something different from what I saw"
    return
  fi
}

The reason the broken test doesn't work is because when we call ./script_to_test, a second instance of the bash interpreter starts, for the second script.

That second instance of bash doesn't have all the variables and functions defined that we have in our first instance. In particular, it doesn't know about our curl function. So when the script asks for curl, all it can find is the binary on the disk.

The line export -f curl says "from now on, any time you start a new bash instance, I want that instance to have this function available too"

You can do the same thing without the -f if you want to export regular variables. The -f here is because our curl isn't a variable, it's a function. In bash it's possible to have a function with the same name as a variable.

Functions and Variables

Oh, and if "it's possible to have a function with the same name as a variable" feels weird, watch what happens when we create a variable named foo and a function named foo in an interactive bash session:

; foo="bar"
; echo $foo
bar
; foo() { echo "blort" ; }
; echo $foo
bar
; foo
blort
; echo $foo
bar

That's why export needs the -f argument if you want it to export a function, rather than a variable.

Unix, Files, and Containers: Can I make something unmockable?

All of this business about using dynamic scoping to reach into a program and re-write bits of it before we run it might be a little uncomfortable. What if I want to write a bash script that makes it hard for someone else to do this?

Well, I don't think it's practical to do so. If you want a lexically scoped language, choose a lexically scoped language. However, there's fun stuff to be learned by trying!

Here's an option:

#!/usr/bin/env bash

my_immutable_function() {
    echo "Probably impossible to override this"
}

mydata="$(my_immutable_function)"
if somethingsomething "$mydata"
then
    otherthings "$mydata"
    ...
fi

...

Notice that the function is declared immediately before it's used. There's no time for any other context to nip in and re-define it. It feels like this will do the trick, but it means your functions can only be declared and used in this one script. We lose the possible advantages of pulling functions out into libraries of their own.

Another option is to stop using functions altogether, and only ever use full paths to scripts:

mydata="$(/home/gds/scripts/myscript)"

That's harder to inject into, because we're not referring to anything by name, we're referring by a full path.

If I wanted to mess with that in a test, I'd probably have to do either of the following hacks:

Hack 1: temporarily replace the script

T_my_test() {
    mv /home/gds/scripts/myscript{,.bak}
    cp /home/gds/script/{stub_script,myscript}
    if somethingsomething "$(function_under_test)"
    then
        mv /home/gds/scripts/myscript{.bak,}
        $T_fail "my failure message"
        return
    else
        mv /home/gds/scripts/myscript{.bak,}
    fi
}

The disadvantage is that this messes with the global state of the system. If anyone else is using /home/gds/scripts/myscript at the time, their stuff will break while our test runs.

Hack 2: chroot

T_my_test() {
    mkdir -p /tmp/testing_jail/home/gds/scripts
    cp /home/gds/script/stub_script /tmp/testing_jail/home/gds/scripts
    if somethingsomething "$(chroot /tmp/testing_jail function_under_test)"
    then
        $T_fail "my failure message"
        return
    fi
}

Look at man 8 chroot and man 2 chroot for details on how that one works. The key is that the command being run thinks that / refers to the directory that we previously thought of as /tmp/testing_jail.

This is a part of the technology behind containers, where we tend to use the slightly more powerful pivot_root. You can find out about that with man 8 pivot_root to learn about the shell command, or man 2 pivot_root to learn about the C function.

There's also a golang function called pivotroot, which I'm pretty sure literally just calls the C one. You can see that function used in practice in Julz Freidman's excellent Build a container in less than 100 lines of go article. I think Julz may have been the first person to live-code a container in a tech talk, but when I search youtube for the video I see lots of other people doing so, and not him. Perhaps he started a trend?

But WHY?

There are lots of nice lexicaly scoped languages out there that aren't bash. Object oriented languages, functional languages, and even other scripting languages. Why do I insist on TDDing bash?

  • It's everywhere
  • It's close to the OS

Everywhere

Most of the tricks above work even if my final scripts are intended to be run in a busybox container with a tiny implementation of /bin/sh. Bash is on (almost) all linux and mac computers by default, and we can write sh-compatible scripts if we want to run in even more places. Sure, I can totally install other language environments in my fleet of cloud machines, but the thing that installs them in the first place is almost always bash.

Close to the OS

When it comes to doing things in and with unix-like operating systems, I think almost nothing can match the tight integration that bash has. Notice that injecting a test double for a "shelled out" command was exactly the same as injecting a test double for a "function". Notice that a discussion about bash unit testing started straying into describing container technology. The UNIX filesystem is a core data structure in the bash language, much like lists in lisp and structs in C.

Unix is written in C, but it's operated in the shell. If you're interested in automating the operation of unix machines, I think bash is a great place to start.

Author: Gareth Smith

Created: 2021-03-26 Fri 15:39

Validate