kaste / mockito-python

Mockito is a spying framework
MIT License
123 stars 12 forks source link

Mockito does not unstub inherited methods #56

Closed stacymiller closed 2 years ago

stacymiller commented 2 years ago

Hello! The following looks like a bug to me, but if it is not — please advice how to circumvent this behaviour.

Steps to reproduce: run the following code

import mockito

class A:
    @staticmethod
    def foo():
        return "result"

class B(A):
    pass

B = mockito.mock()
mockito.unstub()

print(B.foo())

Expected behaviour: "result" is printed. Actual behaviour in mockito-1.3.3 installed from PyPI on Python 3.9: "None" is printed.

I expect that when I first mocked B and then unstubbed all mocks, B.foo() should execute the inherited method A.foo() and return me the string "result". What happens is that B.foo() returns the Dummy object. Which seems strange, because the documentation for unstub() promises to remove all mocks

I believe I saw a similar bug report somewhere in 2016, but I did not manage to find it again and attach it here.

Thanks in advance for any advice!

kaste commented 2 years ago

Ah that works different. You actually did not mock B but assigned a dummy object ("mock") to B. After that the class B(A): gets garbage collected if it's not referenced anywhere else.

You can mock/stub/patch or better: configure the behavior of B without changing the implementation of B. E.g.

In [68]: class A:
    ...:     @staticmethod
    ...:     def foo():
    ...:         return "result"
    ...:
    ...: class B(A):
    ...:     pass
    ...:

In [69]: when(B).foo().thenReturn(12)
Out[69]: <mockito.invocation.AnswerSelector at 0x1a6bf2c2da0>

In [70]: B().foo()
Out[70]: 12

In [71]: B.foo()
Out[71]: 12

In [72]: unstub()

In [73]: B().foo()
Out[73]: 'result'

The documentation you link to says "Unstubs all stubbed methods and functions". (Not: remove all mocks). That usually means restore all original functionality, or: "unpatch".

FWIW unstub does nothing here:

In [75]: C = mock()

In [76]: C.foo()

In [77]: when(C).foo().thenReturn(42)
Out[77]: <mockito.invocation.AnswerSelector at 0x1a6bf16ec80>

In [78]: C.foo()
Out[78]: 42

In [79]: unstub()

In [80]: C.foo()
Out[80]: 42

I don't know if there is a use-case here. Usually mocks are short lived and you just create a new one.

stacymiller commented 2 years ago

Thank you! Now I can see this is an intended behaviour and understand why is it so.

Usually mocks are short lived and you just create a new one.

Usually yes, but not when you're doing Model.query = mock() in the sqlalchemy and this affects the whole test suite >_<

Ok, now I got what's happening here and will try to find a solution. Thank you!

kaste commented 2 years ago

Oh, Model.query = mock(), is that flasks sqlalchemy layer? That is extremely ugly to mock as just accessing Model.query already has a side-effect. (Yeah, sure, it's a getter, LOL.)

I will make a patch so that mock objects are also reset on unstub. (Although I don't think this will help you here.)

The shitty, ahem tricky, thing is that query is a class descriptor, and of course the fluent interface of sqlalchemy is wordy to mock. That's a usecase for #3 actually.

I think the following works:

    query_prop = mock()
    when(query_prop).filter_by(...).thenReturn(
        mock({"first": lambda: "A user"})
    )
    with when(_QueryProperty).__get__(...).thenReturn(query_prop):
        assert User.query.filter_by(username='admin').first() == "A user"

    # or on pytest:
    monkeypatch.setattr(User, "query", query_prop)  # we don't mock the *private* `_QueryProperty`(!)
    assert User.query.filter_by(username='admin').first() == "A user"

Two things for mockito, 1. make patching properties/descriptors easier, 2. support fluent interfaces ("chains").

Ideally:

    with when(User).query.filter_by(...).first().thenReturn("A user"):
        assert User.query.filter_by(username='admin').first() == "A user"

That would be extremely beautiful.