0

So I have this class located in folder/layer/base.py which has something like this in it:

from folder.plugin import load_plugin

class BaseLayer:

  def __init__(self):
    self.tileindex = load_plugin()

I need to add unit tests to already existing functions within that class. My problem is, the function load_plugin() returns an instance of a class located in folder/tileindex/base.py. Because of that, it happens multiple times and in multiple different functions that a line looks like this:

def somefunction(self):
  key = self.tileindex.get_key(...)
  r = self.tileindex.bulk_add(...)
  self.tileindex.add(...)

And I have no clue how to mock that. At first I was mocking load_plugin and returning whatever value so I could assert it afterwards. But now that I've seen these functions that use self.tileindex as an instance of another class I don't know what to do. For example:

def register(self):
        """
        Registers a file into the system
        :returns: `bool` of status result
        """
        items = [item for item in self.items if item['register_status']]
        if len(items) > 1:
            item_bulk = []
            for item in items:
                item_bulk.append(self.layer2dict(item))
            LOGGER.debug('Adding to tileindex (bulk)')
            r = self.tileindex.bulk_add(item_bulk)
            status = r[items[0]['identifier']]

When I mocked load_plugin, the code failed at the last line saying TypeError: 'Mock' object is not subscriptable.

I tried to import the class that is instanciated and mock that directly. But then for some reason I get the error AttributeError: <Group tileindex> does not have the attribute 'base' as soon as I put @patch('folder.tileindex.base').

Is there some way I can mock self.tileindex itself so I can test the rest of the code?

Thanks!

PhilippeTh
  • 15
  • 5

1 Answers1

1

Make sure that to not use unittest.mock.Mock but instead unittest.mock.MagicMock for the reasons stated here. You can follow this documentation about Mocking Classes (all of this will use MagicMock).

For your case, here are 3 options to mock the object returned by load_plugin(). You can choose what best fits your needs.

  • mock_plugin_return_values - Mocks via return_value
  • mock_plugin_side_effect - Mocks via side_effect
  • mock_plugin_stub - Mocks via stubbing the class

File tree

.
├── folder
│   ├── layer
│   │   └── base.py
│   ├── plugin.py
│   └── tileindex
│       └── base.py
└── tests
    └── test_layer.py

folder/layer/base.py

from folder.plugin import load_plugin

class BaseLayer:

    def __init__(self):
        self.tileindex = load_plugin()

    def somefunction(self):
        a = self.tileindex.add("a")
        print("add:", a)

        key = self.tileindex.get_key("a")
        print("get_key:", key)

        r = self.tileindex.bulk_add([1, 2, 3])
        print("bulk_add:", r)

        status = r['identifier']
        print("status:", status)

        return a, key, r, status

folder/plugin.py

from folder.tileindex.base import SomePlugin


def load_plugin():
    return SomePlugin()

folder/tileindex/base.py

class SomePlugin():
    pass

test/test_layer.py

import pytest

from folder.layer.base import BaseLayer


# Note, this requires <pip install pytest-mock>


@pytest.fixture
def mock_plugin_return_values(mocker):
    mock_cls = mocker.patch("folder.plugin.SomePlugin")
    mock_obj = mock_cls.return_value

    mock_obj.add.return_value = "Anything!"
    mock_obj.get_key.return_value = "Something!"
    mock_obj.bulk_add.return_value = {"identifier": "Nothing!"}


@pytest.fixture
def mock_plugin_side_effect(mocker):
    mock_cls = mocker.patch("folder.plugin.SomePlugin")
    mock_obj = mock_cls.return_value

    mock_obj.add.side_effect = lambda arg: f"Adding {arg} here"
    mock_obj.get_key.side_effect = lambda arg: f"Getting {arg} now"
    mock_obj.bulk_add.side_effect = lambda arg: {"identifier": f"Adding the {len(arg)} elements"}

@pytest.fixture
def mock_plugin_stub(mocker):
    # Option 1: Create a new class
    # class SomePluginStub:

    # Option 2: Inehrit from the actual class and just override the functions to mock
    from folder.tileindex.base import SomePlugin
    class SomePluginStub(SomePlugin):

        def add(self, arg):
            return f"Adding {arg} here"

        def get_key(self, arg):
            return f"Getting {arg} now"

        def bulk_add(self, arg):
            return {"identifier": f"Adding the {len(arg)} elements"}

    mocker.patch("folder.plugin.SomePlugin", SomePluginStub)


def test_return_values(mock_plugin_return_values):
    layer = BaseLayer()
    result = layer.somefunction()
    print(result)
    assert result == ('Anything!', 'Something!', {'identifier': 'Nothing!'}, 'Nothing!')


def test_side_effect(mock_plugin_side_effect):
    layer = BaseLayer()
    result = layer.somefunction()
    print(result)
    assert result == ('Adding a here', 'Getting a now', {'identifier': 'Adding the 3 elements'}, 'Adding the 3 elements')


def test_stub(mock_plugin_stub):
    layer = BaseLayer()
    result = layer.somefunction()
    print(result)
    assert result == ('Adding a here', 'Getting a now', {'identifier': 'Adding the 3 elements'}, 'Adding the 3 elements')

Output

$ pytest -q -rP
...                                                                                     [100%]
=========================================== PASSES ============================================
_____________________________________ test_return_values ______________________________________
------------------------------------ Captured stdout call -------------------------------------
add: Anything!
get_key: Something!
bulk_add: {'identifier': 'Nothing!'}
status: Nothing!
('Anything!', 'Something!', {'identifier': 'Nothing!'}, 'Nothing!')
______________________________________ test_side_effect _______________________________________
------------------------------------ Captured stdout call -------------------------------------
add: Adding a here
get_key: Getting a now
bulk_add: {'identifier': 'Adding the 3 elements'}
status: Adding the 3 elements
('Adding a here', 'Getting a now', {'identifier': 'Adding the 3 elements'}, 'Adding the 3 elements')
__________________________________________ test_stub __________________________________________
------------------------------------ Captured stdout call -------------------------------------
add: Adding a here
get_key: Getting a now
bulk_add: {'identifier': 'Adding the 3 elements'}
status: Adding the 3 elements
('Adding a here', 'Getting a now', {'identifier': 'Adding the 3 elements'}, 'Adding the 3 elements')
3 passed in 0.06s
  • Thank you for your answer, it is very detailed and I appreciate it. There's just one thing it's that the `SomePlugin` actually comes from another folder. Inside the `load_plugin` function there is a `module = importlib.import_module(packagename)` and then from that it will return the class with the plugin definition. So what I tried to do was `@patch(folder.tileindex.base)` so that I could use it, but I always get the error `AttributeError: does not have the attribute 'base'`. The import itself `from folder.tileindex.base import module` works, but the patch always fails... – PhilippeTh Sep 23 '21 at 12:27
  • To not deal with all the complexities of `SomePlugin` further, you can just change the line `mock_cls = mocker.patch("folder.plugin.SomePlugin")` into `mock_cls = mocker.patch("folder.layer.base.load_plugin")` so that we would just patch `load_plugin()` directly instead of the class it instantiates. But this will only work for `mock_plugin_return_values` and `mock_plugin_side_effect` and not with `mock_plugin_stub` (though given those facts I think this wouldn't be preferred anyways). Could you try it? – Niel Godfrey Pablo Ponciano Sep 23 '21 at 12:36
  • That was what I was doing that was failing. when doing these lines: `r = self.tileindex.bulk_add(item_bulk)` `status = r[items[0]['identifier']]` it fails saying `TypeError: 'Mock' object is not subscriptable`. – PhilippeTh Sep 23 '21 at 12:46
  • Are you initializing anything as a `unittest.mock.Mock` object manually that you included along with my answer? Or are you using the fixture I already provided in my answer? – Niel Godfrey Pablo Ponciano Sep 23 '21 at 12:50
  • uhm.. Why is the test passing now that I've changed unittest.mock.Mock for just a patch... I thought they were equivalent I guess there's a big difference. I was doing `mocked_load_plugin = Mock()` and then patching my entire test class with `@patch('folder.layer.base.load_plugin', new=mocked_load_plugin)` so I wouldn't have to pass it as an argument to every test function. Now I tried instead passing it to every function and removing the `new` and the test passes... I'm kind of pissed not gonna lie but thanks a lot for your help! – PhilippeTh Sep 23 '21 at 13:00
  • You can see the reason [here](https://stackoverflow.com/questions/64176468/how-to-create-a-subscriptable-mock-object). Glad it helped :) If it's all the answer you need, you might want to accept it as the answer https://stackoverflow.com/help/someone-answers – Niel Godfrey Pablo Ponciano Sep 23 '21 at 13:02