Testing Floating Point Numbers in Go
A Helper Function to Calculate Relative Difference Between Input Numbers
📚 Connect with us. Want to hear what’s new at The Pragmatic Bookshelf? Sign up for our newsletter. You’ll be the first to know about author speaking engagements, books in beta, new books in print, and promo codes that give you discounts of up to 40 percent.
The reason we need floating point numbers is that memory of computers is limited, so we can’t store numbers to an infinite number of places. Floating point notation, based on the IEEE-754 standard, is a way to represent real numbers using 32 or 64 bits encoding. However, even using 64-bit precision it’s impossible to store an infinite number of digits, which means these numbers are rounded at some point, making them inherently imprecise.
This imprecision is usually small and, for many cases, it can be ignored. For other cases, it can cause problems, like when you need to calculate time from these numbers — as happened with the Patriot Missile software failure. Make sure you understand the trade-offs of using floating point numbers according to your requirements. We aren’t going to cover cases where the imprecision matters in this article.
In chapter 5 of Powerful Command-Line Applications in Go we develop a command-line tool that performs calculations on data extracted from comma separated value (CSV) files that contain floating-point number values.
To unit test the functions developed in that program, we perform a direct comparison between floating point numbers, which works well for the example in the book. However, comparing two floating point numbers is unreliable due to their imprecision.
For this article let's assume we can ignore the small imprecision for floating point numbers. Even where the imprecision is acceptable for the application, it could still lead to flaky tests — let’s look at an example case.
Finding the Test Failure: A Decimal Place
To visualize the potential issue when testing code that uses floating point numbers, consider the following example. We have a small Go package, shapes
, that defines a new exported type, Rectangle
, which implements a single method, Perimeter
:
package shapes// Rectangle represents a rectangle shape
type Rectangle struct {
L, W float64
}// Perimeter calculates the perimeter of a shape
func (r Rectangle) Perimeter() float64 {
return 2 * (r.L + r.W)
}
Type Rectangle
defines two float64 fields L and W, representing the length and width of the rectangle. The Perimeter
method calculates the rectangle perimeter and returns it as a float64 value.
Now, let’s define a test case for this package:
package shapesimport (
“testing”
)func TestPerimeter(t *testing.T) {
r := Rectangle{L: 2.03, W: 3.5} exp := 11.06
p := r.Perimeter() if exp != p {
t.Errorf(“Expected %f, got %f”, exp, p)
}
}
Execute this test case using go test -v
to see that it fails:
$ go test -v=== RUN TestPerimeter
rectangle_int_test.go:14: Expected 11.060000, got 11.060000
— — FAIL: TestPerimeter (0.00s)
FAIL
exit status 1
FAIL github.com/rgerardi/shapes 0.004s
Notice that, although the test failed, the message seems to indicate that the two values are the same. You need to increase the number of decimal digits to see the difference by modifying the line in the test that generates the error from this:
t.Errorf(“Expected %f, got %f”, exp, p)
to this:
t.Errorf(“Expected %.18f, got %.18f”, exp, p)
Then, execute the test again. Notice that the output displays the two numbers with a small difference:
$ go test -v=== RUN TestPerimeter
shapes_test.go:12: Expected 11.060000000000000497, got 11.059999999999998721
— — FAIL: TestPerimeter (0.00s)
FAIL
exit status 1
FAIL shapes 0.001s
The difference takes place only on the sixteenth decimal place. For this case, the difference is negligible, but it's still enough to cause a test failure. Let's address this issue by developing a test helper function.
Writing a Test Helper for Floating Comparison
To overcome the issue with testing floating point numbers, we can introduce a small tolerance when comparing two numbers. The goal is no longer ensuring the numbers are the same, but rather testing whether that small difference we noticed in the previous section is negligible.
Let's create a test helper function by taking some inspiration from Go's standard library.
Create a function that takes three floating numbers as input: a,b, and e where a and b represent the two numbers you're testing, and e is the tolerance required for the test. This tolerance is usually known as epsilon. This function returns a boolean value representing whether or not the difference falls within the tolerance:
func withinTolerance(a, b, e float64) bool {
First, handle the case where a direct comparison works. If the two numbers are equal, return true:
if a == b {
return true
}
Now, calculate the absolute difference between the two input numbers:
d := math.Abs(a - b)
Now, you could compare the absolute difference with the tolerance directly. However, this comparison is not ideal since an absolute number that is small for two large input numbers could still be large for two small inputs, resulting in a wrong outcome. Instead, you need to calculate the relative difference between the input numbers by dividing their difference by the second input, b.
To avoid an invalid result when b is zero, return the result of the direct comparison:
if b == 0 {
return d < e
}
For other cases, return the result of the relative difference with the tolerance:
return (d / math.Abs(b)) < e
}
Next, use the helper function in the test case instead of a direct comparison:
func TestPerimeter(t *testing.T) {
r := Rectangle{L: 2.03, W: 3.5} exp := 11.06
p := r.Perimeter() if !withinTolerance(exp, p, 1e-12) {
t.Errorf(“Expected %.18f, got %.18f”, exp, p)
}
}
For this test, we're using a tolerance of "1e-12," meaning if the relative difference is smaller than 12 decimal places, the test passes. Now, run the test to see the result:
$ go test -v=== RUN TestPerimeter
— — PASS: TestPerimeter (0.00s)
PASS
ok shapes 0.002s
This time the test passes because the difference between the two input numbers is smaller than the specified tolerance.
Conclusion
When using floating point numbers in your programs, make sure to understand their benefits and their pitfalls.
Using tolerances to compare floating point numbers may not work for all cases, but for cases where the error is negligible, using this helper function strategy helps improve your test's reliability.
If you enjoyed this article, you might also enjoy Ricardo’s book, published by The Pragmatic Bookshelf: