2

My code under test does sth like this:

def to_be_tested(x):
  return round((x.a + x.b).c())

I would like to test it by passing a Mock object as x. I tried to do it like this:

import unittest
import unittest.mock

class Test_X(unittest.TestCase):
  def test_x(self):
    m = unittest.mock.Mock()
    to_be_tested(m)
    # now check if the proper call has taken place on m

The call to x.a and the one to x.b work as expected. They deliver new mock objects which can be asked how they were created (e. g. via q._mock_parent and q._mock_new_name), so this step works just fine.

But then the addition is supposed to take place which just raises an error (TypeError: unsupported operand type(s) for +: 'Mock' and 'Mock'). I hoped this also would return a mock object so that the call to .c() can take place and (again) return a mock object.

I also considered m.__add__ = lambda a, b: unittest.mock.Mock(a, b) prior to the call of the code under test, but that wouldn't help since not my original Mock is going to be added but a newly created one.

I also tried the (already quite cumbersome) m.a.__add__ = lambda a, b: unittest.mock.Mock(a, b). But that (to my surprise) led to raising an AttributeError: Mock object has no attribute 'c' when calling the code under test. Which I do not understand because the Mock I create there should accept that I called c() in it, right?

Is there a way I can achieve what I want? How can I create a Mock which is capable of being added to another Mock?

Or is there another standard way of unittesting code like mine?

EDIT: I'm not interested in providing a specialized code which prepares the passed mocks for the expected calls. I only want to check after the call that everything has been happening as expected by examining the passed and returned mock objects. I think this way is supposed to be possible and in this (and other complex cases like this) I could make use of it.

Alfe
  • 56,346
  • 20
  • 107
  • 159
  • Downvoter: Please state what you didn't like about my question. – Alfe May 13 '18 at 02:01
  • simple not enough information available. There is no information about `class X`, and 2 `datetime.datetime` objects cannot added together by nature. I do not think I could upvote now if you only add them in coments, I think you have to update the original post – Gang May 13 '18 at 03:11
  • @Gang The original was using a *difference* of two `datetime` objects which is possible and should return a `datetime.timedelta()`. I just wanted to provide a [mcve] without the details. I understand that you might have been able to come up with a specialized version, given all the details, but then it wouldn't have been a proper solution using the mechanisms I wanted to understand and use (passing Mock objects and checking them in afterwards). I added an EDIT section to my Q in order to enable any downvoters to revoke their decision (in case that's wanted). – Alfe May 14 '18 at 09:13
  • 1
    Note that there may be a good reason that `unittest.mock` does not provide a `__round__` mock implementation: the documentation clearly states that such a method should return an Integral (integer) type, which a *mock object is not*. As such you are normally required to provide your own `__round__` implementation. – Martijn Pieters May 15 '18 at 11:39
  • 1
    That said, since all the other numeric rounding methods are there (trunc, ceil, and floor), I've filed https://bugs.python.org/issue33516 to address this in the core library. – Martijn Pieters May 15 '18 at 11:49

2 Answers2

2

Adding objects requires those objects to at least implement __add__, a special method, called magic methods by Mock, see the Mocking Magic Methods section in the documentation:

Because magic methods are looked up differently from normal methods, this support has been specially implemented. This means that only specific magic methods are supported. The supported list includes almost all of them. If there are any missing that you need please let us know.

The easiest way to get access to those magic methods that are supported by mock, you can create an instance of the MagicMock class, which provides default implementations for those (each returning a now MagicMock instance by default).

This gives you access to the x.a + x.b call:

>>> from unittest import mock
>>> m = mock.MagicMock()
>>> m.a + m.b
<MagicMock name='mock.a.__add__()' id='4500141448'>
>>> m.mock_calls
[call.a.__add__(<MagicMock name='mock.b' id='4500112160'>)]

A call to m.a.__add__() has been recorded, with the argument being m.b; this is something we can now assert in a test!

Next, that same m.a.__add__() mock is then used to supply the .c() mock:

>>> (m.a + m.b).c()
<MagicMock name='mock.a.__add__().c()' id='4500162544'>

Again, this is something we can assert. Note that if you repeat this call, you'll find that mocks are singletons; when accessing attributes or calling a mock, more mocks of the same type are created and stored, you can later use these stored objects to assert that the right object has been handed out; you can reach the result of a call with the Mock.return_value attribute:

>>> m.a.__add__.return_value.c.return_value
<MagicMock name='mock.a.__add__().c()' id='4500162544'>
>>> (m.a + m.b).c() is m.a.__add__.return_value.c.return_value
True

Now, on to round(). round() calls a magic method too, the __round__() method. Unfortunately, this is not on the list of supported methods:

>>> round(mock.MagicMock())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: type MagicMock doesn't define __round__ method

This is probably an oversight, since other numeric methods such as __trunc__ and __ceil__ are included. I filed a bug report to request it to be added. You can manually add this to the MagicMock supported methods list with:

mock._magics.add('__round__')   # set of magic methods MagicMock supports

_magics is a set; adding __round__ when it already exists in that set is harmless, so the above is future-proof. An alternative work-around is to mock the round() built-in function, using mock.patch() to set a new round global in the module where your function-under-test is located.

Next, when testing, you have 3 options:

  • Drive tests by setting return values for calls, including types other than mocks. For example, you can set up your mock to return a floating point value for the .c() call, so you can assert that you get correctly rounded results:

        >>> m.a.__add__.return_value.c.return_value = 42.12   # (m.a + ??).c() returns 42.12
        >>> round((m.a + m.b).c()) == 42
        True
    
  • Assert that specific calls have taken place. There are a whole series of assert_call* methods that help you with testing for a call, all calls, calls in a specific order, etc. There are also attributes such as .called, .call_count, and mock_calls. Do check those out.

    Asserting that m.a + m.b took place means asserting that m.a.__add__ was called with m.b as an argument:

    >>> m = mock.MagicMock()
    >>> m.a + m.b
    <MagicMock name='mock.a.__add__()' id='4500337776'>
    >>> m.a.__add__.assert_called_with(m.b)  # returns None, so success
    
  • If you want to test a Mock instance return value, traverse to the expected mock object, and use is to test for identity:

    >>> mock._magics.add('__round__')
    >>> m = mock.MagicMock()
    >>> r = round((m.a + m.b).c())
    >>> mock_c_result = m.a.__add__.return_value.c.return_value
    >>> r is mock_c_result.__round__.return_value
    True
    

There is never a need to go back from a mock result to parents, etc. Just traverse the other way.

The reason your lambda for __add__ doesn't work is because you created a Mock() instance with arguments. The first two arguments are the spec and the side_effect arguments. The spec argument limits what attributes a mock supports, and since you passed in a as a mock object specification and that a object has no attribute c, you get an attribute error on c.

Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
  • The idea of not traversing back from the result and instead traversing forward from the originally passed `Mock` is intriguing. But a refactoring step might have accidentally taken out the `return` statement, so the CUT only returns `None`. The calls still might have all taken place, so this way of testing would not notice the error. No, I think it is reasonable to examine the *result* by how it has come into being. `MagicMock` objects seem like a perfect way of doing this, they just seem to lack some logic of making this possible in all cases and (most of all) easy and readable. – Alfe May 15 '18 at 12:29
  • @Alfe: I didn't say anything about not testing the return value. If `None` is returned, that'd not be equal to `42` be the same object as `mock_c_result.__round__.return_value`, obviously. You combine the different techniques (test the return value, and assert calls have been made). – Martijn Pieters May 15 '18 at 12:37
  • Yes, of course. But I would like to test the return value for it being created in the correct way, not only that for my exemplary input values the returned value is correct. I understand that the idea of testing often is to do it by (lots of) examples and then rely on them covering all cases (checked by test coverage analysis). In many languages this also is the only way possible. But in Python it seems to be possible to do more analytic stuff, so I would like to also analyze the way the return values are created (in some cases). – Alfe May 15 '18 at 15:10
0

I found a solution myself, but it isn't all too beautiful. Bear with me.

The normal Mock objects are prepared to record a lot of treatment they experience but not all. E. g. they will record when they are being called, when there is an attribute being queried, and some more things. They will not, however, record (or accept) if they are e. g. added to each other. Adding is assumed to be a "magic" operation, using a "magic method" (__add__) of the objects and Mocks don't support them.

For these there is another class called MagicMock. MagicMock objects support the magic methods, so adding them works for them. The result will be another MagicMock object which can be asked how it was created (by adding two other MagicMock objects).

Unfortunately, in the current version (3.6.5) the magic method __round__ (which is called when round(o) is called) is not included yet. My guess is they just forgot to list that among the other magic methods like __trunc__, __floor__, __ceil__, etc. When I added it in the sources I could properly test also my code under test including the round() call.

But patching the installed Python modules is not the way to do it of course. Since it is a flaw in the current implementation which I expect will be fixed in the future, my current solution is to only change the internal data structures of the mock module after importing it.

The way my test now look is this:

def to_be_tested(x):
  return round((x.a + x.b).c())

import unittest
import unittest.mock

# patch mock module's internal data structures to support round():
unittest.mock._all_magics.add('__round__')
unittest.mock._magics.add('__round__')

class Test_X(unittest.TestCase):
  def test_x(self):
    m = unittest.mock.MagicMock()
    r = to_be_tested(m)
    # now for the tests:
    self.assertEqual(r._mock_new_name, '()')  # created by calling
    round_call = r._mock_new_parent
    self.assertEqual(round_call._mock_new_name, '__round__')
    c_result = round_call._mock_new_parent
    self.assertEqual(c_result._mock_new_name, '()')  # created by calling
    c_call = c_result._mock_new_parent
    self.assertEqual(c_call._mock_new_name, 'c')
    add_result = c_call._mock_new_parent
    self.assertEqual(add_result._mock_new_name, '()')  # created by calling
    add_call = add_result._mock_new_parent
    self.assertEqual(add_call._mock_new_name, '__add__')
    a_attribute = add_call._mock_new_parent
    b_attribute = add_call.call_args[0][0]
    self.assertEqual(a_attribute._mock_new_name, 'a')
    self.assertEqual(b_attribute._mock_new_name, 'b')
    self.assertIs(a_attribute._mock_new_parent, m)
    self.assertIs(b_attribute._mock_new_parent, m)

Test_X().test_x()

A simple test like self.assertEqual(r, round((m.a + m.b).c())) sadly isn't enough because that does not check the name of the attribute b (and who knows what else).

Alfe
  • 56,346
  • 20
  • 107
  • 159