Search code examples
pythonmockingpytestpython-decoratorspytest-mock

How can I mock a function that isn't called by name?


Suppose I have a decorator that collects all the functions it decorates to be called at some point in the future.

mydecorator.py

class CallLater(object):
    funcs = []

    def __init__(self, func):
        self.funcs.append(func)

    @classmethod
    def call_now(cls, *args, **kwargs):
        for func in cls.funcs:
            func(*args, **kwargs)

Then, I have a function in a module, one of which will be saved by my decorator.

mymodule.py

import logging

from mydecorator import CallLater

logging.basicConfig(level=logging.INFO) 

@CallLater
def log_a():
    logging.info("A")

@CallLater
def log_b():
    logging.info("B")

def log_c():
    logging.info("C")

Now, if I import mymodule and call CallLater.call_now(), log_a and log_b will be called. But let's say that during testing, I want log_b to be substituted with log_c. I'll try to do the replacement with a mock.

mock_test.py

import logging
import pytest

from mymodule import log_a, log_c
from mydecorator import CallLater

logging.basicConfig(level=logging.INFO) 
pytest_plugins = ('pytest_mock',)

def test_mocking(mocker, caplog):
    mocker.patch('mymodule.log_b', log_c)

    CallLater.call_now()

    logs = [rec.message for rec in caplog.records]
    assert logs == ["A", "C"]

But when I run pytest, I see that my mock didn't work.

FAILED mock_test.py::test_mocking - AssertionError: assert ['A', 'B'] == ['A', 'C']

I imagine that 'mymodule.log_b' is the wrong mocking target since it's not being invoked as mymodule.log_b(), but I'm not sure what to use instead in this situation. Any advice is appreciated!


Solution

  • This is not possible as such, the problem being that the functions are already assigned to the list at load time. The only way I can see to patch this would be to patch CallLater.funcs directly, which is a bit awkward, because you have to replace log_b by log_c manually - but here it goes:

    import logging
    from unittest import mock
    
    from mymodule import log_c
    from mydecorator import CallLater
    
    logging.basicConfig(level=logging.INFO)
    
    
    def test_mocking(caplog):
        funcs = [log_c if f.__name__ == 'log_b' else f for f in CallLater.funcs]
        with mock.patch.object(CallLater, 'funcs', funcs):
            CallLater.call_now()
    
            logs = [rec.message for rec in caplog.records]
            assert logs == ["A", "C"]
    

    Note that you cannot compare directly to the function (e.g. f == log_b), because log_b is the decorated function, not the function as saved in CallLater.funcs.