Feature proposal: unittest.mock.NAN

I think we should define a unittest.mock.NAN constant that can be used with Mock.assert_called_with() to assert that an argument passed to a Mock was NaN. NaNs are special in that math.nan != math.nan, so you can’t just do assert_called_with(math.nan). The naming is meant to parallel unittest.mock.ANY.

Here is a reference implementation:

class _EqNaN:
    def __eq__(self, other):
        return math.isnan(other)

NAN = _EqNaN()

The alternative is that users can just define this EqNaN class themselves as needed in test code. I encountered the need to test for a NaN argument today and was surprised to find that (as far as I can tell) there is no pre-built solution to this in unittest or pytest. It feels like it should be included in some standard library.

What makes NAN special vs other mathematical concepts like positive/negative numbers or decimal numbers?

Would we also want to have EqPositive, EqNegative, EqDecimal, EqZero?

Without being able to say why NAN is special compared to things like that, it kind of seems like a bit stretch for the standard lib.

This:

In [3]: np.nan == np.nan
Out[3]: False

could mean that

my_mock.assert_called_with(value=np.nan)

will always be False, even if the mock was in fact called with value=np.nan.

Unless assert_called_with internally checks isnan on every input, and special cases handling those values, which might in fact be a nicer UX to make things “just work”.

Edit: that does in fact appear to be the case?

In [9]: from unittest.mock import MagicMock
   ...: foo = MagicMock(return_value=3)

In [10]: foo(value=np.nan)
Out[10]: 3

In [11]: foo.assert_called_with(value=np.nan)

In [12]:

@kerrickstaley are you saying the code above raises an AssertionError for you?

In that particular case though, since nan != nan we wouldn’t really want it to pass on assert_called_with since … nan != nan.

I guess if we have to go one way or the other, I’d rather the sentinel, but still am not sure if it makes sense for the stdliib. I imagine that there are more objects with seemingly-odd behavior than just nan.

Kind of makes me think maybe there should be a assert_called_with_... that allows you to pass a function for each parameter and have its verification done via that.

Say we called it assert_called_with_verify:

Works fine:

a = MagicMock()
a.hello(math.nan)

# takes the given arg, passes it to the function, asserts if any of the func(arg) return a non-truthy value.
a.hello.assert_called_with_verify(math.isnan)

Would assert:

a = MagicMock()
a.hello(3)

# Asserts since math.isnan(3) -> False
a.hello.assert_called_with_verify(math.isnan)

At least if this type of thing exists, it can help a lot here, and be very flexible.

Edit:

It seems to only do the described behavior using float('nan') but not np.nan or math.nan

In [21]: foo = MagicMock(return_value=3)

In [22]: foo(float('nan'))
Out[22]: 3

In [23]: foo.assert_called_with(float('nan'))
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
Cell In [23], line 1
----> 1 foo.assert_called_with(float('nan'))

File C:\Python311\Lib\unittest\mock.py:923, in NonCallableMock.assert_called_with(self, *args, **kwargs)
    921 if actual != expected:
    922     cause = expected if isinstance(expected, Exception) else None
--> 923     raise AssertionError(_error_message()) from cause

AssertionError: expected call not found.
Expected: mock(nan)
Actual: mock(nan)

In [24]: import numpy as np

In [25]: foo = MagicMock(return_value=3)

In [26]: foo(np.nan)
Out[26]: 3

In [27]: foo.assert_called_with(np.nan)

I think, as a purely practical matter, most people would, in fact want it to work “like a regular value”. Which is probably why it does already seem to work like regular value, as I demonstrated above.

1 Like

Ah. It must be using an is check or something instead of or in addition to == somewhere.

and that explains why float('nan') doesn’t work :slight_smile:

In [37]: float('nan') is float('nan')
Out[37]: False

That make sense but also does not seem sensible for assert_called_with to do (on the grounds that a half-measure worse than no measure at all) :person_shrugging:

I think you are seeing that tuple comparison works with nans, since the IEEE 754 rules don’t apply:

>>> import math
>>> math.nan == math.nan
False
>>> (math.nan,) == (math.nan,)
True

It’s not exactly that “the IEEE 754 rules don’t apply”, just that tuple comparison does is before == on elements internally. It’s usually a nice optimization but it leads to surprising behavior with NaN.

As for the original proposal, this seems useful and simple to maintain, so I wouldn’t oppose adding it to the standard library. It’s also of course simple to implement yourself (once you know the trick).

2 Likes

Thanks for the clarification. In any case, one cannot rely on this, because there are many NaN values, and at least two of them (quiet and signalling) can regularly be encountered. So I’m +1 for the proposal.

And just to expand, the behavior quickly breaks down if you use different NaNs:

>>> import math
>>> [math.nan] == [math.nan]
True
>>> [float("nan")] == [float("nan")]
False
>>> import pickle
>>> [math.nan] == [pickle.loads(pickle.dumps(math.nan))]
False

It’s not even because there are many distinct NaN values, just that the behavior only applies if it’s the exact same NaN object on both sides: even if the bit pattern is the same, but it’s a different object, the lists won’t be detected as equal.

1 Like

Perhaps cache the NaN object internally like small integers, None, True, False, ..., etc. then?

There isn’t a single NaN object though, that’s the problem.

But what’s wrong with refactoring both float('nan') and math.nan (and perhaps even numpy.nan) to make them always return the very same object?

You could argue that there should be only one object of float("1.0") since it carries no other information, but NaNs carry additional payload, and there’s a good reash they aren’t equal to each other or even themselves.

I suppose you could have a “singleton NaN” object, but the problem is, that would then fail to match against any OTHER NaN object.

This is a problem of testing, so I would expect that the solution should be capable of handling any NaN that a function might return. Let’s say that spam(1, 2, -3) ought to return NaN. You need to write a test that ensures that it returns NaN. You can’t afford to have it fail to match when the function did the right thing. So a magic NaN needs to consider itself to be a match to any NaN object.

1 Like

My point is that we should elevate NaN to the same level as True, such that all expressions that produce NaN should share the same identity, so that developers can simply use the is operator to check if a value is NaN.

In other words, we want this to be True:

float('inf') - float('inf') is float('nan')

in the same vein as:

(1 == 1) is True # True

Once NaN is interned, Mock.assert_called_with would automatically work with NaN because it uses tuple equivalence to compare calls:

And tuple equivalence already prioritizes identity equivalence over value equivalence:

nan = float('nan')
print(nan == nan) # outputs False
print((nan,) == (nan,)) # outputs True

That isn’t true of any other floats though, and it’s not even guaranteed to be true for integers. (It does happen to be for small ones in CPython.) Are you asking for this to also be true?

float("5.0") - float("3.0") is float("2.0")

Because if not, then why is NaN, which specifically has different values that are all NaN, special?

1 Like

Because NaN is special and should’ve been a singleton to begin with. No other float shares the same quality of returning False when comparing to itself on value equivalence. That quality alone makes it deserve a special-cased treatment.

And more importantly, making float('inf') - float('inf') is float('nan') true solves a real-world problem. Making float("5.0") - float("3.0") is float("2.0") true solves nothing because float("5.0") - float("3.0") == float("2.0") is true already.

I can see the argument that infinity should be a singleton (and negative infinity another singleton), but NaN shouldn’t be one on the basis of its IEEE 754 comparison behaviour. What that DOES justify is different behaviour in a testing context, which is what’s being discussed here. It doesn’t justify being a singleton object.

1 Like