Tests

This tutorial shows how the unit test library can be used to write and run tests.

The code presented in this tutorial is available in the directory root/tutorials/unit in the Storm release. You can run it by passing the -t flag to Storm: storm -t tutorials.unit

Setup

First, we need to create somewhere to work. For this tutorial, it is enough to create a file unit.bs somewhere on your system. The file can be empty for the time being.

After creating the file, open a terminal and change to the directory where you created the file. Then run it by typing:

storm unit.bs -t unit

If done correctly, Storm should print the message Passed all 0 tests and exit. Note that based on how you have installed Storm, you might need to modify the command-line slightly.

The first part of the command line (unit.bs) tells Storm to load the file unit.bs into the package unit, as we have done in previous tutorials. By default, this will cause Storm to look for a function named main in the loaded code. However, the -t flag overrides this behavior. The flag -t unit asks Storm to run all unit tests in the unit package that was just loaded, instead of attempting to run the function main.

Writing Tests

To make things more interesting, let's start by writing a small function to test. For simplicity, we will use the iterative implementation of the fibonacci function below:

Int fibonacci(Int n) {
    Int current = 1;
    Int previous = 1;

    for (Int i = 1; i < n; i++) {
        Int next = current + previous;
        previous = current;
        current = next;
    }

    return current;
}

Some readers may already have noticed that the implementation above does indeed not work as we would expect. Let's create some tests to illustrate this!

The unit test library is built around creating tests. Each test is essentially a function that may contain check statements. A test is defined using the test keyword, followed by a name for the test. To be able to use this syntax in Basic Storm, the test library must first be included. As such, we add the use statement to the top of our file:

use test;

And then we can define a suite towards the bottom of the file:

test Fibonacci {
    check fibonacci(1) == 1;
    check fibonacci(2) == 1;
    check fibonacci(3) == 2;
}

Now, we can run the test using storm unit.bs -t unit to see if our implementation works as we expect. Sadly, this is not the case as Storm produces the following output:

Running Fibonacci...
Failed: fibonacci(2) == 1 ==> 2 == 1
Failed: fibonacci(3) == 2 ==> 3 == 2
Passed 1 of 3 tests
Failed 2 tests!

The output ends with a summary that states that only one of our three check statements succeeded, and that 2 failed. Above, we see the lines Failed: that indicate which tests failed. These lines both start by reproducing the expression that was tested. Then it outputs a long arrow (==>), followed by a representation of what the left- and right-hand side of the operator evaluated to. In the case of fibonacci(2) == 1, the left-hand side was 2 and the right-hand side was 1. This expansion is to make it easier to see why the test failed.

In our case, the reason is that the fibonacci implementation is "shifted" by one position. That is, fibonacci(0) evaluates fibonacci(1) and so on. We can fix this by modifying the initialization of previous as follows:

Int fibonacci(Int n) {
    Int current = 1;
    Int previous = 0;

    for (Int i = 1; i < n; i++) {
        Int next = current + previous;
        previous = current;
        current = next;
    }

    return current;
}

With this modification, Storm will now print:

Passed all 3 tests

Testing for Exceptions

Let's assume that we wish to extend our implementation of fibonacci to also support the case where n is zero, and to properly reject negative inputs.

Note: A better way to clearly signal that negative inputs are invalid would be to change the function to accept Nat instead of Int. There are, however, cases where this is not as easy as for the case of fibonacci, so we accept Int as input to illustrate how to use exceptions in the test library.

We can easy add a check statement for the case where n is zero as follows:

test Fibonacci {
    check fibonacci(0) == 0;
    check fibonacci(1) == 1;
    check fibonacci(2) == 1;
    check fibonacci(3) == 2;
}

The test currently fails, but we can easily fix it by adding a special case for zero as follows:

Int fibonacci(Int n) {
    if (n == 0) {
        return 0;
    }

    Int current = 1;
    Int previous = 0;

    for (Int i = 1; i < n; i++) {
        Int next = current + previous;
        previous = current;
        current = next;
    }

    return current;
}

In the case where n is below zero, we expect fibonacci to throw the exception NotSupported. We can test for this case using the keyword throws in a test statement as follows:

test Fibonacci {
    check fibonacci(0) == 0;
    check fibonacci(1) == 1;
    check fibonacci(2) == 1;
    check fibonacci(3) == 2;

    check fibonacci(-1) throws NotSupported;
}

If we run the test currently, we will see that the new test fails with the following output:

Running Fibonacci...
Failed: fibonacci(-1) ==> did not throw NotSupported as expected.
Passed 4 of 5 tests
Failed 1 test!

As we can see above, the message follows a similar structure to before, but here it prints that NotSupported was not thrown rather than expanding the expression. We can fix the issue by adding an appropriate check to the start of our fibonacci function:

Int fibonacci(Int n) {
    if (n == 0) {
        return 0;
    } else if (n < 0) {
        throw NotSupported("n must not be negative");
    }

    Int current = 1;
    Int previous = 0;

    for (Int i = 1; i < n; i++) {
        Int next = current + previous;
        previous = current;
        current = next;
    }

    return current;
}

And with this change, all tests pass again.

Running Tests Programmatically

So far we have used the -t flag on the command line to run the tests. It is of course possible to interact with the tests programmatically as well.

Each test is represented as a regular function in the name tree. As such, we can simply run the function to run the tests inside of it:

void main() {
    TestResult result = Fibonacci();
    print("Failed: ${result.failed}");
    print(result.toS);
}

As can be seen above, test suites return an instance of the TestResult class that contains information about the tests that were executed. It contains member variables that count the number of tests executed, the number of failures, and so on. The code above prints the number of failed tests, and then prints the string representation of the entire result object. If we run the program using storm unit.bs (i.e. without -t unit), the main function will be executed, and we will see the following output:

Failed: 0
Passed all 5 tests

As we see from the output, the string representation of the TestResult object is what was printed by the test library automatically. As such, it is quite easy to implement custom logic for running individual test suites.

It is also possible to run sets of test suites automatically by using the runTests function provided by the library. The function accepts the name of a package that contains the tests that we wish to execute, and optionally also parameters specifying the verbosity of the run. Since the function accepts a Package instance, it is convenient to use the named{} construct from lang:bs:macro to get the current package:

void main() {
    TestResult = runTests(named{});
    print("Failed: ${result.failed}");
}

If we run this code, we can see that the runTests function produces some additional status messages and prints the result automatically by default. This can of course be disabled by passing false as the second parameter to the function:

Running Fibonacci...
Passed all 5 tests
Failed: 0

Final Notes

It is worth noting that check statements can appear anywhere inside a test function. This means that it is possible to use loops and if-statements to programmatically determine which checks should be executed. We could, for example, do the following to verify that fibonacci throws an exception for many negative numbers if we wished to (the benefit is, however, debatable):

test Negative {
    for (Int i = -1; i > -10; i--) {
        check fibonacci(i) throws NotSupported;
    }
}

In other cases, the code in a test might realize that the implementation is too broken to even execute tests. In situations like this, it is possible to use the abort; keyword to abort the test altogether.

Finally, it is worth noting that it is not necessary to place the tests in the same file as the code that is being tested. As with most things in Storm, the test suite can be placed anywhere that is convenient. As such, it is often beneficial to place tests in a sub-package named tests. This makes it so that Storm does not have to compile the tests whenever the tested code is used. Test cases can even be structured into a hierarcy of packages, where each package represents a suite of tests. In such cases, the -t flag has to be replaced by -T in order to instruct the system to traverse packages recursively.