Using assertAlmostEquals to compare to N Significant Figures

I want a Python unittest.assertAlmostEquals that compares numbers to N significant figures, instead of N decimal places.

>>> test.assertAlmostEquals(1e-8, 5e-14)
>>>

Even though these two numbers differ by a factor of 5 million, when comparing them to seven decimal places (assertAlmostEquals default behaviour) they are equal.

I’ve made a first stab this by overriding assertAlmostEquals on a subclass of TestCase, which retains the behaviour of the original, but provides an extra keyword parameter sigfig.

The code and a unittest for it are below. One of the tests currently fails, due to floating-point inaccuracy problems. I’ll have to look at it some more. Isn’t this problem already solved somewhere?

import math
import unittest
 
class TestCase(unittest.TestCase):
    "Augments the assert methods of a unittest.TestCase"
 
    def __str__(self):
        return "%s.%s" % (self.__class__.__name__, self.__testMethodName)
 
    def _assertAlmostEquals(self, a, b, sigfig=7):
        if sigfig < 1:
            raise ValueError("assertAlmostEquals: 'sigfig' must be >=1")
 
        magarg = a or b
        if (magarg == 0) or ((a - b) == 0):
            return
 
        magnitude = int(math.floor((math.log10(magarg))))
        margin = 10**(magnitude - sigfig + 1) / 2.0
        diff_gt_margin = abs(a - b) - margin > -1e-15
        if diff_gt_margin:
            msg = '%s != %s to %d significant figures' % (a, b, sigfig)
            raise AssertionError(msg)
 
    def assertAlmostEquals(self, a, b, places=None, sigfig=None):
        if places is not None and sigfig is not None:
            raise ValueError("assertAlmostEquals: "
                "cannot specify both 'places' and 'sigfig'")
        elif places is not None:
            unittest.TestCase.assertAlmostEquals(self, a, b, places)
        elif sigfig is not None:
            self._assertAlmostEquals(a, b, sigfig)
        else:
            unittest.TestCase.assertAlmostEquals(self, a, b)
 
    def assertRaisesWithMessage(
        self, exceptionType, message, func, *args, **kwargs):
 
        expected = "Expected %s(%r)," % (exceptionType.__name__, message)
        try:
            func(*args, **kwargs)
        except exceptionType, e:
            if str(e) != message:
                msg = "%s got %s(%r)" % (
                    expected, exceptionType.__name__, str(e))
               raise AssertionError(msg)
         except Exception, e:
             msg = "%s got %s(%r)" % (expected, e.__class__.__name__, str(e))
             raise AssertionError(msg)
         else:
             raise AssertionError("%s no exception was raised" % expected)

And a test:

import unittest
 
from TestCase import TestCase
 
class TestableTestCase(TestCase):
 
    def testNothing(self):
        pass # pun intended
 
class AssertAlmostEqualsTest(TestCase):
 
    def setUp(self):
        self.test = TestableTestCase("testNothing")
 
    def testPreservesUnittestBehaviour(self):
        a = 0.1234567
        eps1 = 0.000000049
        self.test.assertAlmostEquals(a, a + eps1)
        self.test.assertAlmostEquals(a, a - eps1)
 
        eps2 = 0.000000051
        self.assertRaises(AssertionError,
            self.test.assertAlmostEquals,
            a, a + eps2)
        self.assertRaises(AssertionError,
            self.test.assertAlmostEquals,
            a, a - eps2)
 
    def testPreservesUnittestBehaviourWithPlaces(self):
        a = 0.1234567
        eps1 = 0.000049
        self.test.assertAlmostEquals(a, a + eps1, places=4)
        self.test.assertAlmostEquals(a, a - eps1, places=4)
 
        eps2 = 0.000051
        self.assertRaises(AssertionError,
            self.test.assertAlmostEquals,
            a, a + eps2, places=4)
        self.assertRaises(AssertionError,
            self.test.assertAlmostEquals,
            a, a - eps2, places=4)
 
    def testRaisesIfPlacesAndSigfigSpecified(self):
        self.assertRaisesWithMessage(ValueError,
            "assertAlmostEquals: "
                "cannot specify both 'places' and 'sigfig'",
            self.test.assertAlmostEquals,
            0, 0, places=4, sigfig=4)
 
    def assertSigFig(self, factor):
        self.assertRaises(AssertionError,
            self.test.assertAlmostEquals,
            1.23 * factor, 1.225 * factor, sigfig=3)
        self.assertAlmostEquals(1.23 * factor, 1.2251 * factor, sigfig=3)
        self.assertAlmostEquals(1.23 * factor, 1.2349 * factor, sigfig=3)
        self.assertRaises(AssertionError,
            self.test.assertAlmostEquals,
            1.23 * factor, 1.235 * factor, sigfig=3)
 
    def testSigfigNormal(self):
        self.assertSigFig(1)
        self.assertSigFig(1e-6)
        self.assertSigFig(1e+6)
        self.assertSigFig(1e+12)
        self.assertSigFig(1e-12)
 
    def testSigFigOfTwo(self):
        self.assertRaises(AssertionError,
            self.test.assertAlmostEquals,
            1.2, 1.15, sigfig=2)
        self.assertAlmostEquals(1.2, 1.151, sigfig=2)
        self.assertAlmostEquals(1.2, 1.249, sigfig=2)
        self.assertRaises(AssertionError,
            self.test.assertAlmostEquals,
            1.2, 1.25, sigfig=2)
 
    def testSigFigOfOne(self):
        self.assertRaises(AssertionError,
            self.test.assertAlmostEquals,
            1, 0.5, sigfig=1)
        self.assertAlmostEquals(1, 0.51, sigfig=1)
        self.assertAlmostEquals(1, 1.49, sigfig=1)
        self.assertRaises(AssertionError,
            self.test.assertAlmostEquals,
            1, 1.5, sigfig=1)
 
    def testRaisesIfSigFigZeroOrLess(self):
        self.assertRaisesWithMessage(ValueError,
            "assertAlmostEquals: 'sigfig' must be >=1",
            self.test.assertAlmostEquals,
            1, 1, sigfig=0.99)
        self.assertRaisesWithMessage(ValueError,
            "assertAlmostEquals: 'sigfig' must be >=1",
            self.test.assertAlmostEquals,
            1, 1, sigfig=0)
        self.assertRaisesWithMessage(ValueError,
            "assertAlmostEquals: 'sigfig' must be >=1",
            self.test.assertAlmostEquals,
            1, 1, sigfig=-1)
 
    def testHandlesArgsOfZero(self):
        self.assertAlmostEquals(0, 0)
        self.assertAlmostEquals(0, 0, places=4)
        self.assertAlmostEquals(0, 0, sigfig=4)
 
    def testHandlesIdenticalArgs(self):
        self.assertAlmostEquals(1.234, 1.234)
        self.assertAlmostEquals(1.234, 1.234, places=4)
        self.assertAlmostEquals(1.234, 1.234, sigfig=4)
 
if __name__ == "__main__":
    unittest.main()

5 thoughts on “Using assertAlmostEquals to compare to N Significant Figures

  1. Well, as I’ve said before – although significant figures is an improvement on the brain dead default unittest assert behaviour it still isn’t fine grained enough for some situations. I’d like to see a ‘delta’ option where you can specify the allowed degree of error in absolute terms (which may be half a significant figure or even one tenth). This behaviour is also symmetric. :-)

  2. Um. Can I comment on my own post? I think so. There are several problems with the above. For one, the behaviour isn’t symmetric with regard to the compared numbers. i.e. assertAlmostEquals(A, B) will not always give the same result as assertAlmostEquals(B, A). Needs a rethink.

Leave a Reply