All

Practical TDD and numerical precision

mgreco

20/09/2018

No Comments

The Test Driven Development (TDD) philosophy improves your productivity and helps you write better code. But if you are new at it, you might find some trouble with its procedures. Let’s dive into a simple example that (hopefully) will help you solve it.

When applying TDD methodology, the objective is to have the most robust and reliable code. To do so, we would need to get all the tests passed, and that’s a difficult task.  

Suppose we want to develop a rounding function, defined as follows: considering a given number, choose how many decimals you want to preserve, and round off the rest of them by adding 1 to that decimal if the next value is equal to or greater than 5, otherwise 0. E.g., let’s try with the number 1.56. I want to round to one decimal place, so 1.56 becomes 1.6 because 6 is equal than or greater to 5. Consequently, 1.54 would become 1.5. That’s our definition, the one that you may have been taught at school, but there are many others. Here you can find some more.

After this point, we will go through a simplification of the steps of the TDD methodology. Those steps are described in the this Wikipedia article.

Let’s add a test

According to the definition we have just seen, we know that a given input should return an expected value. We will build on this approach to implement the tests. First thing we should do is to define these test, and this is what TDD suggests about it:

Once we have defined the testing functions, we will test our solutions. For example: in Python we have a built-in function that does the rounding operation, called “round”. Let’s see if it matches our definition:

Run all the tests

As we can see, this is not what we were looking for. If we check the documentation (something that we should have done in the first place!), we verify that “round” is designed to be efficient and not to accumulate errors. More concretely: “values are rounded to the closest multiple of 10 to the power minus ndigits” and that “rounding is done toward the even choice”, which clearly does not fit with our definition of the function.

As an alternative, we can think of trying Numpy’s “round”. Let’s see what happens:

Write the code and run the tests

Here we find the same “problem”: it doesn’t fit in our definition. It seems that our only choice will be to implement our own function, by following our initial definition. So we’ll need to build it. If we investigate/reason for a little while, we can come up with a function like the following one:

Great! We have a winner, we have passed all the tests. At this point, we may think that the function is ready to be part of our project. But… not so fast! At this point, TDD suggests us to iterate again. This means to make the effort of thinking how we can break the tests with a new corner case. Otherwise, a bug may show up and someone will report it and probably will need to be fixed later. So, whether if someone finds it or if we’ve found it ourselves by doing this exercise we might be in front of a corner case like this:  

Looking for a corner case

Assume, for example, that the input parameter “a” is obtained by combining the output of two functions. What we want to round is the sum of the two numbers obtained from these functions. So we could have something like the following:

What’s wrong here? A typical problem of numerical precision shows up. This kind of issues is also documented as a note in the documentation of the Python “round” function. What is happening is that sometimes we have to deal with numbers that cannot be exactly represented. This generates a small numerical error when functions are chained, because errors are accumulated and, as a result, we find cases like the one mentioned above. We have not taken this potential problem into account and the function returns an unexpected value, causing the test to fail.

Fixing the code

This experience should prevent us from making this kind of error when making numerical comparisons. How do we do that? We should be more flexible when making comparisons, and that involves rewriting the functions if needed, for example, in the following way:

Let’s fix the function:

Let’s fix the test:

Repeat all over again

 

We can keep iterating until we have a robust code. If we consider that we already have it, all that is left is to do a “git commit” of the changes.

The simple example we’ve gone through was quite suitable for TDD methodology. However, when we have many functions’ layers involved, and there’s also a relationship of interdependence between functions, this kind of workflow doesn’t work that well and its implementation may turn into a losing battle. In that case, probably the best thing to do is to test individually the functions involved and assume that we cannot have all under control.

Happy coding!

References: