There are 2 things affecting the mocking of __del__:
The monkeypatching of methods is reverted once the test function ends
This is mentioned in the monkeypatch API reference and can also be seen from the code of the monkeypatch fixture itself, where it calls the MonkeyPatch.undo() method:
@fixture
def monkeypatch() -> Generator["MonkeyPatch", None, None]:
"""A convenient fixture for monkey-patching.
...
All modifications will be undone after the requesting test function or
fixture has finished. ...
"""
mpatch = MonkeyPatch()
yield mpatch
mpatch.undo() # <----------------
As noted in this other answer, the timing when __del__ is called (i.e. when the object is destroyed and garbage-collected) is not something you can guarantee or expect to happen by the time the test function raises the AssertionError. It is only called when there are no more references to it:
CPython implementation detail: It is possible for a reference cycle to prevent the reference count of an object from going to zero. In this case, the cycle will be later detected and deleted by the cyclic garbage collector. A common cause of reference cycles is when an exception has been caught in a local variable. The frame’s locals then reference the exception, which references its own traceback, which references the locals of all frames caught in the traceback.
Considering these 2 things, what happens is that the monkeypatching of __del__ was undone or reverted before the MyClass object c was finally deleted (when its __del__ function was called). Since we are dealing with an exception here, it's likely that a reference to the local variables surrounding the exception was still stored somewhere, so the reference count for the c instance did not become zero while its __del__ was still patched.
I tried to verify this by running the test with the --full-trace and --showlocals options. You'll see that in a function called _multicall, the test function is run and the exception is captured:
$ pytest tests/1.py --setup-show --full-trace --showlocals
# ...lots of logs...
hook_impls = [<HookImpl plugin_name='python', plugin=<module '_pytest.python' from '/path/to//lib/python3.8/site-packages/_pytest/python.py'>>]
caller_kwargs = {'pyfuncitem': <Function test_PatchWithFailure>}, firstresult = True
def _multicall(hook_impls, caller_kwargs, firstresult=False):
# ...other parts of function...
else:
res = hook_impl.function(*args)
if res is not None:
results.append(res)
if firstresult: # halt further impl calls
break
except BaseException:
excinfo = sys.exc_info()
finally:
# ...other parts of function...
> return outcome.get_result()
args = [<Function test_PatchWithFailure>]
caller_kwargs = {'pyfuncitem': <Function test_PatchWithFailure>}
excinfo = (<class 'AssertionError'>, AssertionError('assert False'), <traceback object at 0x1108ea800>)
firstresult = True
# ...lots of logs...
../../path/to/lib/python3.8/site-packages/pluggy/callers.py:197:
From what I can understand, is that the function is called in hook_impl.function (see passed args), then the assert False happens, which is caught in the except block, and the exception info is stored in excinfo. This exception info then stores a reference to the c instance in its traceback object.
# In test_PatchWithFailure
print('>>>> NOW IN test_PatchWithFailure')
c = MyClass()
print(c)
# Logs:
# <1.MyClass object at 0x10de152e0> # <---- SAME as BELOW
# In _multicall
except BaseException:
print('>>>> NOW IN _multicall')
excinfo = sys.exc_info()
import inspect
# print(inspect.trace()[-1]) # The last entry is where the exception was raised
# print(inspect.trace()[-1][0]) # The frame object
# print(inspect.trace()[-1][0].f_locals) # local vars
print(f'{inspect.trace()[-1].lineno}, {inspect.trace()[-1].code_context}')
print(f'Is "c" in here?: {"c" in inspect.trace()[-1][0].f_locals}')
print(inspect.trace()[-1][0].f_locals['c'])
# Logs:
# 42, [' assert False\n']
# Is "c" in here?: True
# <1.MyClass object at 0x10de152e0> # <---- SAME as ABOVE
I'm not sure if what I did above is correct, but I think that:
- When pytest captured the AssertionError, it effectively adds a reference to the local
c object, preventing it from being __del__-ed when the function ends
- Then the monkeypatching is undone/reverted while
c is still not garbage-collected
- Then pytest finally reports all the errors it collected after all the test functions are run (which releases references to the
c instance, making it __del__-able)
- Then finally
c is __del__-ed, but the monkeypatch is already gone
----------------------------------------------------------------- Captured stdout call ------------------------------------------------------------------
>>>> NOW IN test_PatchWithFailure
<1.MyClass object at 0x10de152e0>
>>>> NOW IN _multicall
42, [' assert False\n']
Is "c" in here?: True
<1.MyClass object at 0x10de152e0>
>>>> NOW IN _multicall
42, [' assert False\n']
Is "c" in here?: True
<1.MyClass object at 0x10de152e0>
--------------------------------------------------------------- Captured stdout teardown ----------------------------------------------------------------
>>>> NOW returned from yield MonkeyPatch, calling undo()
>>>> UNDOING <class '1.MyClass'> __del__ <function MyClass.__del__ at 0x10bf04e50>
>>>> UNDOING <class '1.MyClass'> __init__ <function MyClass.__init__ at 0x10bf04d30>
================================================================ short test summary info ================================================================
FAILED tests/1.py::test_PatchWithFailure - assert False
=================================================================== 1 failed in 0.16s ===================================================================
Destroying MyClass
Now for
How could I work around this issue?
Rather than relying on the timing when objects are finally deleted and on monkeypatch-ing __del__, a workaround is to subclass MyClass instead, then completely overriding/replacing both __init__ and __del__:
def test_PatchWithFailure():
class MockedMyClass(MyClass):
def __init__(self):
print('Calling mocked __init__')
super().__init__()
def __del__(self):
print('Calling mocked __del__')
c = MockedMyClass()
assert False
See Overriding destructors without calling their parents. Since, the derived class does not call the parent class' __del__, it won't be called during the tests. It is similar to monkeypatching that replaces the method with something else, but here the definition of __del__ remains mocked for the entire duration of the test. All other functionality of MyClass should still be usable/testable from MockedMyClass.
c = MockedMyClass()
> assert False
E assert False
tests/1.py:59: AssertionError
----------------------------------------------------------------- Captured stdout call ------------------------------------------------------------------
Calling mocked __init__
Constructing MyClass
================================================================ short test summary info ================================================================
FAILED tests/1.py::test_PatchWithFailure - assert False
=================================================================== 1 failed in 0.13s ===================================================================
Calling mocked __del__
Here, we see that destroying c calls only the mocked __del__ (which effectively does nothing here). There is no more "Destroying MyClass", which hopefully solves your problem. It should be straightforward to create a fixture that provides a MockedMyClass instance.
@pytest.fixture
def mocked_myclass():
class MockedMyClass(MyClass):
def __init__(self):
print('Calling mocked __init__')
super().__init__()
def __del__(self):
print('Calling mocked __del__')
return MockedMyClass()
def test_PatchWithFailure(mocked_myclass):
c = mocked_myclass
assert False