I seem to have got somewhere with this. It's important that a test should always leave things as it found them... but only one of the other answers here seems to address that point: if you are substituting a mock or fake for a real decorator, you have to restore that real decorator after the test.
In module thread_check.py I have a decorator called thread_check
which (this is a PyQt5 context) checks to see that a function or method is called in the "right thread" (i.e. Gui or non-Gui). It looks like this:
def thread_check(gui_thread: bool):
def pseudo_decorator(func):
if not callable(func):
raise Exception(f'func is type {type(func)}')
def inner_function(*args, **kwargs):
if QtWidgets.QApplication.instance() != None:
app_thread = QtWidgets.QApplication.instance().thread()
curr_thread = QtCore.QThread.currentThread()
if gui_thread != None:
if (curr_thread == app_thread) != gui_thread:
raise Exception(f'method {func.__qualname__} should have been called in {"GUI thread" if gui_thread else "non-GUI thread"}')
return func(*args, **kwargs)
return inner_function
return pseudo_decorator
In practice, in my case here, it makes more sense in most cases to patch out this decorator completely, for all tests, with a "do-nothing decorator" at the start of each run. But to illustrate how it can be done on a per-test basis, see below.
The problem posed is that a method such as is_thread_interrupt_req
of class AbstractLongRunningTask
(in fact it's not abstract: you can instantiate it) must be run in a non-Gui thread. So the method looks like this:
@thread_check(False) # i.e. non-Gui thread
def is_thread_interrupt_req(self):
return self.thread.isInterruptionRequested()
This is how I solved the question of patching the thread_check
decorator, in a way which cleans up the "module space" to restore the real decorator for the next test:
@pytest.fixture
def restore_tm_classes():
yield
importlib.reload(task_manager_classes)
@pytest.mark.parametrize('is_ir_result', [True, False]) # return value from QThread.isInterruptionRequested()
@mock.patch('PyQt5.QtCore.QThread.isInterruptionRequested')
def test_ALRT_is_thread_interrupt_req_returns_val_of_thread_isInterruptionRequested(mock_is_ir, request, qtbot, is_ir_result, restore_tm_classes):
print(f'\n>>>>>> test: {request.node.nodeid}')
print(f'thread_check.thread_check {thread_check.thread_check}')
def do_nothing_decorator(gui_thread):
def pseudo_decorator(func):
return func
return pseudo_decorator
with mock.patch('thread_check.thread_check', side_effect=do_nothing_decorator):
importlib.reload(task_manager_classes)
with mock.patch('PyQt5.QtCore.QThread.start'): # NB the constructor calls QThread.start(): must be mocked!
tm = task_manager_classes.TaskManager(task_manager_classes.AbstractLongRunningTask)
mock_is_ir.return_value = is_ir_result
assert tm.task.is_thread_interrupt_req() == is_ir_result
def test_another(request):
print(f'\n>>>>>> test: {request.node.nodeid}')
print(f'thread_check.thread_check {thread_check.thread_check}')
... in test_another
we get the following printed out:
thread_check.thread_check <function thread_check at 0x000002234BEABE50>
... which is the same object as was printed out at the start of the test_ALRT...
test.
The key here is to use side_effect
in your patch in combination with importlib.reload
to reload your module which is itself going to use the decorator.
Note the context manager indenting here: the patch on thread_check.thread_check
only needs to apply to the reload
... by the time the actual method (is_thread_interrupt_req
) is called, the fake decorator is in place.
There is something quite strange going on here if you don't use this teardown fixture restore_tm_classes
: in fact in the next test method, it then appears (from my experiments) that the decorator will neither be the real one nor the do_nothing_decorator
, as I ascertained by putting in print
statements in both. So if you don't restore by reloading the tweaked module it appears that the app code in the task_manager_classes
module is then left, for the duration of the test suite, with a "zombie decorator" (which appears to do nothing).
Caveat
There are big potential problems when you use importlib.reload
in the middle of a test run.
In particular it can then turn out that the app code is using class X with a certain id value (i.e. id(MyClass)
) but the test code (in this and subsequently run modules) is using supposedly the same class X but having another id value! Sometimes this may not matter, other times it can lead to some rather baffling failed tests, which can probably be solved, but may require you to
prefer to avoid mock.patch
ing objects which have not been created actually inside the test: when for example a class itself (I'm not thinking here of an object of a class, but the class as variable itself) is imported or created outside any tests and thus is created in the test collection phase: in this case the class object will not be the same as the one after the reload.
even to use importlib.reload(...)
inside some fixtures in various modules which had previously worked without this!
Always use pytest-random-order
(with multiple runs) to reveal the full extent of such (and other) problems.
As I said, the decorator could simply be patched out at the start of the run. Whether it's therefore worth doing this is another matter. I have in fact implemented the reverse situation: where the thread_check
decorator is patched out at the start of the run, but then patched back in, using the above importlib
techniques, for one or two tests which need the decorator to be operative.