Why does patching the destructor (__del__) not work for failing tests?

Viewed 474

monkeypatch is an awesome tool in pytest allowing one to replace any function in the scope of the current test. One of the greatest things is that even constructors can be patched. Unfortunately, however, I have troubles patching the destructor. It seems to work only when the test is successful. The regular constructor is called in case the test fails. Consider this code:

class MyClass:
    def __init__(self):
        print("Constructing MyClass")
    def __del__(self):
        print("Destroying MyClass")

def test_NoPatch():
    c = MyClass()

def test_Patch(monkeypatch, mocker):
    monkeypatch.setattr(MyClass, '__init__', mocker.MagicMock(return_value=None))
    monkeypatch.setattr(MyClass, '__del__', mocker.MagicMock(return_value=None))
    c = MyClass()

def test_PatchWithFailure(monkeypatch, mocker):
    monkeypatch.setattr(MyClass, '__init__', mocker.MagicMock(return_value=None))
    monkeypatch.setattr(MyClass, '__del__', mocker.MagicMock(return_value=None))
    c = MyClass()
    assert False

will give the following result:

====================================================================================================== test session starts ======================================================================================================
platform linux -- Python 3.8.5, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 -- /home/julian/devel/tests/test_pytest_monkeypatch/testenv/bin/python3
cachedir: .pytest_cache
rootdir: /home/julian/devel/tests/test_pytest_monkeypatch
plugins: mock-3.5.1
collected 3 items                                                                                                                                                                                                               

test.py::test_NoPatch Constructing MyClass
Destroying MyClass
PASSED
test.py::test_Patch PASSED
test.py::test_PatchWithFailure FAILED

=========================================================================================================== FAILURES ============================================================================================================
_____________________________________________________________________________________________________ test_PatchWithFailure _____________________________________________________________________________________________________

monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f7e94e03490>, mocker = <pytest_mock.plugin.MockerFixture object at 0x7f7e94e222b0>

    def test_PatchWithFailure(monkeypatch, mocker):
        monkeypatch.setattr(MyClass, '__init__', mocker.MagicMock(return_value=None))
        monkeypatch.setattr(MyClass, '__del__', mocker.MagicMock(return_value=None))
        c = MyClass()
>       assert False
E       assert False

test.py:19: AssertionError
==================================================================================================== short test summary info ====================================================================================================
FAILED test.py::test_PatchWithFailure - assert False
================================================================================================== 1 failed, 2 passed in 0.03s ==================================================================================================
Destroying MyClass

The first test without patching prints out the messages as expected. The second test is silent, as expected. In the third test, the message from the constructor is suppressed, the message from the destructor is, however, printed.

Is this a bug or a feature? How could I work around this issue?

2 Answers

There are 2 things affecting the mocking of __del__:

  1. 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()  # <----------------
    
  2. 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:

  1. 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
  2. Then the monkeypatching is undone/reverted while c is still not garbage-collected
  3. 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)
  4. 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

Python doesn't guarantee that__del__() methods are called for objects that still exist when the interpreter exits and has some other implications which are better explained by the official docs:

https://docs.python.org/3/reference/datamodel.html

I.e. if you want to make sure this method is called, you have to ensure that the object is garbage collected before the interpreter begins to shut down, e.g. use pytest fixtures etc

Related