Float precision in Ruby is a well known quirk. But when testing floats, not many of us bother to remember this and make their tests respectful to this quirk. In this post we will see how the popular Ruby testing frameworks help us test floats properly.
Last week I published a post about migrating a test suite from RSpec to Minitest. What was very interesting is that I got a mention on Twitter from Ryan Davis with an offer for a code review of the migration. Here’s the convo:
Ryan did the review for me, and one of his comments was:
Lets see why…
Ruby Float (im)precision
Float numbers cannot store decimal numbers properly. The reason is that Float is a binary number format. What do I mean? Well, Ruby always converts Floats from decimal to binary and vice versa.
Think about this very simple example. Whats the result of 1 divided by 3? Yup, 0.33333333333… The result of this calculation is 0.333(3), with 3 repeating until infinity.
This same rule, or quirk, applies to binary numbers. When a decimal number is converted to binary, the resulting binary number can be endless. Mathematically this is all fine. But in practice, my MacBook Air doesn’t have endless memory. I am running just on 4GBs of RAM, so Ruby must cut off the endless number at some point. Or it will fill up the whole memory of the computer and it will become useless. This mechanism of rounding numbers produces a rounding error and that’s exactly what we have to deal with here.
So, the base rule about this is: do not represent currency (or, money) with Float.
Take this for an example. Simple calculation. We want to add 0.1 to 0.05, which should return 0.15. Right? Lets give it a try:
Okay, what? Let’s see what’s the result of the addition:
You can see that Ruby rounds off the number at the end. Lets print this number with 50 decimal points:
Whoa! You can see that the actual result of this addition is quite different from 0.15. Ruby here rounds off the numbers, because the difference is so “microscopic”.
If you are curious, here’s how the numbers 0.10 and 0.05 actually look like with 50 decimal points in Ruby:
Okay, so now when the problem is obvious, how can we test it? The best way to test this is to use a delta number. You can think of this delta number as a number showing the margin of rounding error.
For example, the delta for the 0.10 + 0.05 operation is approximately 0.0000000000000001.
Minitest provides us the
It fails unless the expected and the actual values are within delta of each other.
This means that, while we will expect to get 0.15 as a result, the rounding-off error can be as big as 0.0000000000000001.
If you see the source of this method, it’s quite easy to understand:
The delta must be larger or equal than the absolute value of the result of subtraction of the expected and the actual values.
This method behaves in a different matter than
example, if we use the delta as an epsilon, this test will fail:
Why this happens is easier to see in the source of the
assert_in_epsilon is a wrapper for
assert_in_delta with a small but
important difference. The delta here is subject to “auto-scaling”. This means
that it will increase for the product of the smaller number from the expected
value/actual value pair and the epsilon.
Also, I guess, it’s called “epsilon” because the greek letter Epsilon is usually used to denote a small quantity (like a margin of error) or perhaps a number which will be turned into a zero within some limit.
RSpec provides us the
be_within matcher. The same rules apply here as the
assert_in_delta method. The format is:
Or, in our case:
Since the people behind RSpec and Minitest are awesome, they have provided us these methods where we can easily smooth out the edges of testing floats. What’s very important to understand here is that while testing this is pretty easy, it’s extremely important to know what to use when.
When it comes to money/currency, every sane developer out there will use BigDecimal. It provides arbitrary-precision floating point decimal arithmetic, which means that it will always get a correct result for any calculation involving floating point numbers.
As an outro, I’ll leave you with this tweet: