Search code examples
pythonunit-testingpytestpytest-mock

pytest mocker fixture mock module from where it is defined and not where it is used


I have some utils function at src/utils/helper.py

Imagine I have a function called func_a in utils/helper.py and it is used at multiple places in my project.

And every time I use it, I import it like this

from src.utils.helper import func_a

Now I want to mock this func_a in my tests.

I want to create a fixture in conftest.py so that I don't need to write a mock function again and again for each test file.

The problem is, in my mock function I CANNOT write like this.

https://pypi.org/project/pytest-mock/

mocker.patch('src.utils.helper.func_a', return_value="some_value", autospec=True)

I have to write it like this for each test file

mocker.patch('src.pipeline.node_1.func_a', return_value="some_value", autospec=True)

As per the docs https://docs.python.org/3/library/unittest.mock.html#where-to-patch

Since I am importing func_a like from src.utils.helper import func_a I have to mock where it is used and not where it is defined.

But the problem with this approach is that I can not define it in my fixture in conftest.py

Directory Structure

├── src
│   ├── pipeline
│   │   ├── __init__.py
│   │   ├── node_1.py
│   │   ├── node_2.py
│   │   └── node_3.py
│   └── utils
│       ├── __init__.py
│       └── helper.py
└── tests
    ├── __init__.py
    ├── conftest.py
    └── pipeline
        ├── __init__.py
        ├── test_node_1.py
        ├── test_node_2.py
        └── test_node_3.py

Solution

  • Well, as you wrote, you have to use that patching if you use from xxx import. Your first option is of course to use full module import in the production code instead:

    node_1.py

    import src.utils.helper
    
    def node_1():
        src.utils.helper.func_a()
    

    I`m sure that you are aware of this, but I wanted to mention it anyway.

    If you don't want to change the production code, you have to do the patching depending on the patched module, as you wrote. That basically means that you have to construct the patch location dynamically. Provided you have a symmetric naming of the tested functions and the test functions, you could do something like this:

    conftest.py

    @pytest.fixture
    def mock_func_a(mocker, request):
        node_name = request.node.name[5:]  # we assume that the test function name is "test_node_1" for testing "node_1"
        module_path = f'src.pipeline.{node_name}.func_a'
        mocked = mocker.patch(module_path,
                              return_value="some_value",
                              autospec=True)
        yield mocked
    

    If you can't derive the patch path from the test itself, you have to add more information to the test function. That probably makes only sense if you want to do more than just a patch in the fixture - otherwise you could also just add a patch decorator directly.
    You could add a custom mark that has the module path, or a part of the module path as an argument:

    test_node_1.py

    @pytest.mark.node("node_1")
    def test_node(mock_func_a):
        node_1()
        mock_func_a.assert_called_once()
    

    conftest.py

    @pytest.fixture
    def mock_func_a(mocker, request):
        mark = next((m for m in request.node.iter_markers()
                     if m.name == 'node'), None)  # find your custom mark
        if mark is not None:
            node_name = mark.args[0]
            module_path = f'src.pipeline.{node_name}.func_a'
            mocked = mocker.patch(module_path,
                                  return_value="some_value",
                                  autospec=True)
            yield mocked
    

    Or, if you need to provide the full path:

    test_node_1.py

    @pytest.mark.path("src.pipeline.node_1")
    def test_node(mock_func_a):
        ...
    

    conftest.py

    @pytest.fixture
    def mock_func_a(mocker, request):
        mark = next((m for m in request.node.iter_markers()
                     if m.name == 'path'), None)  # find your custom mark
        if mark is not None:
            node_name = mark.args[0]
            module_path = f'{node_name}.func_a'
            ...