Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Mocking side_effect while autospeccing gives the function additional argument

So the example code is very basic:

@mock.patch.object(BookForm, 'is_valid')
def test_edit(self, mocked_is_valid):
    create_superuser()
    self.client.login(username="test", password="test")

    book = Book()
    book.save()

    mocked_is_valid.side_effect = lambda: True

    self.client.post(reverse('edit', args=[book.pk]), {})

This works well. But adding autospec keyword to the mock:

@mock.patch.object(BookForm, 'is_valid', autospec=True)

causes additional argument to be passed to the side_effect callable, which obviously results in error:

TypeError: <lambda>() takes 0 positional arguments but 1 was given

What I don't uderstand, is why autospeccing gives additional argument. I've read the docs, but still can't find the explanation of this behaviour.

Theoretically, it's written that

In addition mocked functions / methods have the same call signature as the original so they raise a TypeError if they are called incorrectly.

so it'd be okay (is_valid has self argument, which is probably what is being passed here), but on the other hand it's also written about side_effect that

The function is called with the same arguments as the mock, and unless it returns DEFAULT, the return value of this function is used as the return value.

So as far as I understand, side_effect should be called with the self argument even without autospeccing. But it is not.

is called with the same arguments as the mock

if form.is_valid():  # the mock is_valid is called with the self argument, isn't it?

So if someone could explain it to me, preferably quoting the docs, I'd be thankful.

like image 857
c2974301 Avatar asked May 27 '16 09:05

c2974301


1 Answers

You are misunderstanding the documentation. Without autospec, the side_effect that is being called is literally as is without inspecting the original declaration. Let's create a better minimum example to demonstrate this issue.

class Book(object):
    def __init__(self):
        self.valid = False
    def save(self):
        self.pk = 'saved'
    def make_valid(self):
        self.valid = True

class BookForm(object):
    def __init__(self, book):
        self.book = book
    def is_valid(self):
        return self.book.valid

class Client(object):
    def __init__(self, book):
        self.form = BookForm(book)
    def post(self):
        if self.form.is_valid() is True:  # to avoid sentinel value
            print('Book is valid')
        else:
            print('Book is invalid')

Now your original test should work about the same with some adjustments

@mock.patch.object(BookForm, 'is_valid')
def test_edit(mocked_is_valid):
    book = Book()
    book.save()
    client = Client(book)
    mocked_is_valid.side_effect = lambda: True
    client.post()

Running the test as is will cause Book is valid be printed to stdout, even though we haven't gone through the dance to set the Book.valid flag to true, as the self.form.is_valid being called in Client.post is replaced with the lambda which is invoked. We can see this through a debugger:

> /usr/lib/python3.4/unittest/mock.py(962)_mock_call()
-> ret_val = effect(*args, **kwargs)
(Pdb) pp effect
<function test_edit.<locals>.<lambda> at 0x7f021dee6730>
(Pdb) bt
...
  /tmp/test.py(20)post()
-> if self.form.is_valid():
  /usr/lib/python3.4/unittest/mock.py(896)__call__()
-> return _mock_self._mock_call(*args, **kwargs)
  /usr/lib/python3.4/unittest/mock.py(962)_mock_call()
-> ret_val = effect(*args, **kwargs)

Also within the frame of the Client.post method call, it's not a bound method (we will get back to this later)

(Pdb) self.form.is_valid
<MagicMock name='is_valid' id='140554947029032'>

So hmm, we might have a problem here: the side_effect could literally be any callable that could be different to what reality is, in our case the is_valid function signature (that is the argument list) could be different to the mock we provide. What if the BookForm.is_valid method was modified to take in an additional argument:

class BookForm(object):
    def __init__(self, book):
        self.book = book
    def is_valid(self, authcode):
        return authcode > 0 and self.book.valid

Rerun our test... and you will see that our test has passed, even though Client.post is still calling BookForm.is_valid without any arguments. Your product will fail in production even though your test has passed. This is why autospec argument is introduced, and we will apply that in our second test without replacing the callable through side_effect:

@mock.patch.object(BookForm, 'is_valid', autospec=True)
def test_edit_autospec(mocked_is_valid):
    book = Book()
    book.save()
    client = Client(book)
    client.post()

This now happens when calling the function

Traceback (most recent call last):
  ...
  File "/tmp/test.py", line 49, in test_edit_autospec
    client.post()
  File "/tmp/test.py", line 20, in post
    if self.form.is_valid():
  ...
  File "/usr/lib/python3.4/inspect.py", line 2571, in _bind
    raise TypeError(msg) from None
TypeError: 'authcode' parameter lacking default value

Which is what you want and what autospec intends to provide - a check before the mocks are called, and

In addition mocked functions / methods have the same call signature as the original so they raise a TypeError if they are called incorrectly.

So we have to fix the Client.post method by providing an authcode greater than 0.

    def post(self):
        if self.form.is_valid(123) is True:
            print('Book is valid')
        else:
            print('Book is invalid')

Since our test didn't mock the is_valid function via the side_effect callable, the method will end up printing Book is invalid.

Now if we want to provide the side_effect, it will have to match the same signature

@mock.patch.object(BookForm, 'is_valid', autospec=True)
def test_edit_autospec(mocked_is_valid):
    book = Book()
    book.save()
    client = Client(book)
    mocked_is_valid.side_effect = lambda self, authcode: True
    client.post()

Book is valid will now be printed again. Going through the debugger to inspect that autospec'd and mocked is_valid object within the frame of the Client.post method call

(Pdb) self.form.is_valid
<bound method BookForm.is_valid of <__main__.BookForm object at 0x7fd57f43dc88>>

Ah, somehow the method signature is not a simple MagicMock object (recall the <MagicMock name='is_valid' id='140554947029032'> noted previously) and is a properly bound method, which means the self argument is now passed into the mock, solving this:

side_effect: A function to be called whenever the Mock is called. See the side_effect attribute. Useful for raising exceptions or dynamically changing return values. The function is called with the same arguments as the mock...

The "same arguments as the mock" in this case means the same as whatever that was passed into the mock. To reiterate, the first case the self.form.is_valid was replaced with a naked, unbounded callable so self is never passed; and in the second case the callable is now bound to self, both the self AND authcode will be passed into the side_effect callable - just as what would happen in the real call. This should reconciled the perceived misbehavior of interactions with autospec=True for mock.patch.object and manually defined side_effect callable for a mock.

like image 89
metatoaster Avatar answered Nov 15 '22 13:11

metatoaster