0

I'm unit testing a module I wrote and encountering a problem with default class object provided to a function that has a mock for it. This is how it looks in high level:

main_file.py

class MainClass(object):
    def main_func(self):
        sub_class_obj = SubClass()
        sub_class_obj.sub_func()

sub_file.py

class SubClass(object):
    def sub_func(self, my_att=Helper(2)):
        self.my_att = my_att

helpers.py

class Helper():
    def __init__(self, my_val):
        self.my_val = my_val

test.py

class TestClass(object):
    @patch('sub_file.Helper', MockHelper)
    def my_test(self):
        main_class_obj = MainClass()
        main_class_obj.main_func()

When I do that in a way that my_att is provided - all works well and the Mock is called, but when I don't and the default value is set - I get the original Helper class object.

Any idea how to make the default value for this attribute to receive the mock as well?

Thanks in advance!

MrBean Bremen
  • 14,916
  • 3
  • 26
  • 46
Siv
  • 3
  • 1

2 Answers2

0

The problem is that the default value is read at import time, so it is already set in the function before you patch Helper. The defaults are saved in the function object at that point.

You can, however, also patch the default arguments of your function (which can be accessed via __defaults__:

from sub_file import SubClass

class TestClass(object):
    @patch('sub_file.Helper', MockHelper)
    @patch.object(SubClass.sub_func, "__defaults__", (MockHelper(),))
    def my_test(self):
        main_class_obj = MainClass()
        main_class_obj.main_func()

Note that __defaults__ has to be a tuple of arguments.

You could also use monkeypatch to do the same:

from sub_file import SubClass

class TestClass(object):
    @patch('sub_file.Helper', MockHelper)
    def my_test(self, monkeypatch):
        monkeypatch.setattr(SubClass.sub_func, "__defaults__", (MockHelper(),)     
        main_class_obj = MainClass()
        main_class_obj.main_func()

UPDATE:
I didn't realize that this would not work with Python 2. Apart from the other name of the default arguments (func_defaults instead of __defaults__) this would only work with standalone functions, but not with methods, as setattr is not supported in this case in Python 2. Here is a workaround for Python 2:

from sub_file import SubClass

class TestClass(object):
    @patch('sub_file.Helper', MockHelper)
    def my_test(self):
        orig_sub_func = SubClass.sub_func
        with patch.object(SubClass, "sub_func",
                          lambda o, attr=MockHelper(): orig_sub_func(o, attr)):
            main_class_obj = MainClass()
            main_class_obj.main_func()

This way, the original sub_func is replaced by a function that has its own default value, but otherwise delegates the functionality to the original function.

UPDATE 2:
Just saw the answer by @chepner, and it is correct: the best way would be to refactor your code accordingly. Only if you cannot do this, you try this answer.

MrBean Bremen
  • 14,916
  • 3
  • 26
  • 46
  • And what if the sub class is the __init__? It doesn't seem to work... I tried: ``` @patch.object(SubClass.__init__, "func_defaults", (MockHelper(),)) ``` and got this error - AttributeError: 'instancemethod' object has no attribute 'func_defaults' (I'm working with Python 2.7, hence the func_defaults instead of __defaults__...) – Siv Aug 08 '21 at 12:09
  • I had overlooked that this was in Python 2, sorry. I added an update for Python 2. – MrBean Bremen Aug 08 '21 at 16:43
0

Default values are created when the function is defined, not when it is called. It's too late to patch Helper in your test, because SubClass.__init__ has already been defined.

Rather than patching anything, though, re-write MainClass so that there is no hard-coded reference to SubClass: then you can create the proper instance yourself without relying on a default value.

You can pass an instance directly:

class MainClass(object):
    def main_func(self, sub_class_obj):
        sub_class_obj.sub_func()

class TestClass(object):
    def my_test(self):
        mock_obj = MockHelper()
        main_class_obj = MainClass(mock_obj)
        main_class_obj.main_func()

or take a factory function that will be called to create the subclass object.

class MainClass(object):
    def main_func(self, factory=SubClass):
        sub_class_obj = factory()
        sub_class_obj.sub_func()

class TestClass(object):
    def my_test(self):
        main_class_obj = MainClass(lambda: SubClass(MockHelper()))
        main_class_obj.main_func()
chepner
  • 497,756
  • 71
  • 530
  • 681