0

I'm trying to spy on a class object and see if class instance method is called with right parameters.

I can't use Mock(wraps=obj_instance) because the flow I trigger creates its own object instance.

I tried to use Mock(spec=obj_class) as seen in below, but it doesnt work.

Any ideas on how to spy on instance methods without creating the instance beforehand?

from mock import Mock
import unittest


class CreditAPI:
    def __init__(self, *args, **kwargs):
        self.user = kwargs['user']

    def add_credit(self, amount):
        print("adding amount %s to user %s" % (amount, self.user))


def consume_credit():
    credit_api = CreditAPI(user="Ali")
    credit_api.add_credit(50)



class ConsumeCreditTestCase(unittest.TestCase):
    def test_consume_credit(self):
        m_credit_api = Mock(spec=CreditAPI)
        consume_credit()
        m_credit_api.__init__.assert_called_with(user="Ali")  # doesnt work
        m_credit_api.add_credit.assert_called_with(50)  # doesnt work


if __name__ == '__main__':
    unittest.main()
jonrsharpe
  • 115,751
  • 26
  • 228
  • 437
Ali Yılmaz
  • 1,657
  • 1
  • 11
  • 28
  • What you're seeing is that consume_credit isn't very testable. You'd need to patch out the real CreditAPI at the module level with the current design. – jonrsharpe Jun 03 '20 at 07:19
  • thanks @jonrsharpe, I indeed patched creditAPI methods and it looks like I got it working half way. Posted my efforts below. – Ali Yılmaz Jun 03 '20 at 07:24

2 Answers2

0

With @jonrsharpe's help I managed to get it working by using spec like this:

class ConsumeCreditTestCase(unittest.TestCase):
    def test_consume_credit(self):
        with patch('__main__.CreditAPI', spec=CreditAPI) as m_credit_api:
           consume_credit()
           m_credit_api.assert_called_with(user="Ali")
           m_credit_api().add_credit.assert_called_with(50)

Ali Yılmaz
  • 1,657
  • 1
  • 11
  • 28
  • You need to replace the whole thing, not patch bits of it: `with patch('__main__.CreditAPI', spec=CreditAPI) as m_credit_api:`. Note that your assertions would be `m_credit_api.assert_called_with` (*not* `__init__`) and `m_credit_api().add_credit.assert_called_with` or `m_credit_api.return_value.add_credit.assert_called_with` - see https://docs.python.org/3/library/unittest.mock.html#patch. – jonrsharpe Jun 03 '20 at 08:59
  • thank you a ton @jonrsharpe I managed to get it working your way. editing my answer :) – Ali Yılmaz Jun 03 '20 at 09:33
  • @jonrsharpe if would you like to post this as an answer, I'll be happy to mark it as approved :) – Ali Yılmaz Jun 03 '20 at 09:36
  • If you're using that second one successfully I would suggest editing out the first part and accepting your own answer! Note that per https://docs.python.org/3/library/unittest.mock.html#patch-methods-start-and-stop if you're using `.start()` *"you must ensure that the patching is “undone” by calling `stop`"*, which is why the context manager is encouraged. – jonrsharpe Jun 03 '20 at 09:45
  • 1
    edited out :) You're right about the start-stop thing. In my real-life case, the `act` part does not take place in the test code itself (I have dedicated routines for `arrange` and `assert`), so I can't use context manager there. But FWIW I edited my answer with CM as it better fits into the mock example here. – Ali Yılmaz Jun 03 '20 at 09:50
  • hello again @jonrsharpe, sorry for bothering over and over. Looks like the example above do not work: `add_credit` method seems indeed mocked instead of performing original functionality. ideas? – Ali Yılmaz Jun 04 '20 at 16:15
  • *"seems indeed mocked instead of performing original functionality"* - why did you expect otherwise? If you want the original functionality, *don't mock it*. – jonrsharpe Jun 04 '20 at 16:16
  • is it possible to `spy` using unittest, i.e. watch what argument are passed while preserving original functionality? – Ali Yılmaz Jun 04 '20 at 16:17
  • the `spy_decorator` wrapper (which I edited out yesterday) was indeed doing that. was kinda hoping I could perform that in a more elegant way. – Ali Yılmaz Jun 04 '20 at 16:17
  • spy_decorator ref: https://stackoverflow.com/questions/50427397/spying-on-class-instantiation-and-methods – Ali Yılmaz Jun 04 '20 at 16:18
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/215323/discussion-between-ali-yilmaz-and-jonrsharpe). – Ali Yılmaz Jun 04 '20 at 16:23
  • You can set up a mock that `wraps` the real thing, that's covered in the docs. However! Calling through is a bit of a test/design smell. – jonrsharpe Jun 04 '20 at 16:23
  • I tested `wraps`, but looks like it only works with instances. I have to pass an instance to `wraps` for it to work. In my case, instance is created at runtime, I don't have it beforehand. That was the actual question. – Ali Yılmaz Jun 04 '20 at 16:32
  • But you can create an identical instance - you're already asserting that the `CreditAPI` is instantiated with `"Ali"`, so you can wrap `CreditAPI("Ali")`. And this shows the problem with trying to test at this layer (and not inverting the dependency); they're coupled. The immediate problem seemed to be one of not actually replacing the thing you're trying to mock, but it seems like this is an XY problem with deeper roots - without concrete context, it's not really answerable. – jonrsharpe Jun 04 '20 at 17:11
0

Goal - How to spy on a class without any instance?
Answer - This does not appear to be possible as discovered above. However, what is possible is to only spy on a single method of the class using path.object. This preserves all other original method calls, so only mocking one method of the class is required.

class ConsumeCreditTestCase(unittest.TestCase):
    def test_consume_credit(self):
        with patch.object(CreditAPI, 'add_credit') as add_credit:
            consume_credit()
            add_credit.assert_called_with(50)

For more updates, follow this post. This post suggests that it may be possible to redirect back to original method, but it didn't work for me. I will update if I find I missed something.

This post helped me to better understand this scenario.

gagarwa
  • 1,426
  • 1
  • 15
  • 28