为什么修补析构函数 (__del__) 对失败的测试不起作用?

Jul*_*ich 4 python unit-testing monkeypatching pytest python-3.x

monkeypatch是 pytest 中的一个很棒的工具,允许您替换当前测试范围内的任何函数。最棒的事情之一是甚至可以修补构造函数。然而不幸的是,我在修补析构函数时遇到了麻烦。它似乎只有在测试成功时才有效。如果测试失败,则调用常规构造函数。考虑这个代码:

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
Run Code Online (Sandbox Code Playgroud)

将给出以下结果:

====================================================================================================== 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
Run Code Online (Sandbox Code Playgroud)

第一个没有打补丁的测试按预期打印出消息。正如预期的那样,第二个测试是无声的。在第三个测试中,来自构造函数的消息被抑制,但是来自析构函数的消息被打印出来。

这是错误还是功能?我该如何解决这个问题?

Gin*_*pin 5

有两件事会影响 的嘲讽__del__

  1. 一旦测试功能结束,方法的monkeypatching就会恢复

    这在monkeypatch API 参考中提到,也可以从monkeypatchfixture 本身的代码中看出,它调用MonkeyPatch.undo()方法:

    @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()  # <----------------
    
    Run Code Online (Sandbox Code Playgroud)
  2. 正如在另一个答案中所指出的,__del__被调用的时间(即对象被销毁和垃圾收集时)并不是您可以保证或期望在测试函数引发AssertionError. 只有在没有更多引用时才会调用它:

    CPython 实现细节:引用循环可以防止对象的引用计数变为零。在这种情况下,循环稍后将被循环垃圾收集器检测并删除。引用循环的一个常见原因是在局部变量中捕获了异常。帧的局部变量然后引用异常,它引用它自己的回溯,它引用在回溯中捕获的所有帧的局部变量。

考虑到这两件事,发生的事情是在最终删除对象__del__之前(调用其函数时),monkeypatching被撤消或恢复。由于我们在这里处理异常,因此对异常周围局部变量的引用可能仍存储在某处,因此实例的引用计数在其仍被修补时并未变为零。MyClassc__del__c__del__

我试图通过使用--full-trace--showlocals选项运行测试来验证这一点。您将看到在名为 的函数中_multicall,测试函数运行并捕获异常:

$ 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: 
Run Code Online (Sandbox Code Playgroud)

据我所知,函数被调用hook_impl.function(见passed args),然后assert False发生,在except块中被捕获,异常信息存储在excinfo. 然后,此异常信息将对该c实例的引用存储在其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
Run Code Online (Sandbox Code Playgroud)

我不确定我上面所做的是否正确,但我认为:

  1. 当pytest捕获到AssertionError时,它有效地添加了对本地c对象的引用,防止__del__在函数结束时被-ed
  2. 然后monkeypatching被撤消/恢复,而c仍然没有被垃圾收集
  3. 然后pytest所有测试函数运行最终报告它收集的所有错误(释放对c实例的引用,使其成为__del__-able)
  4. 然后finallyc__del__-ed,但是monkeypatch已经不见了
----------------------------------------------------------------- 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
Run Code Online (Sandbox Code Playgroud)

现在为

我该如何解决这个问题?

而不是依赖于对象最终被删除和 on monkeypatch-ing 的时间__del__,一种解决方法是子类化MyClass,然后完全覆盖/替换__init____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
Run Code Online (Sandbox Code Playgroud)

请参阅覆盖析构函数而不调用其父对象。由于派生类不调用父类' __del__,因此在测试期间不会调用它。它类似于用其他方法替换方法的monkeypatching,但这里的定义__del__在整个测试期间保持模拟。的所有其他功能MyClass仍应从MockedMyClass.

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
Run Code Online (Sandbox Code Playgroud)

在这里,我们看到销毁c只调用被模拟的__del__(这里实际上什么都不做)。不再有“Destroying MyClass”,它有望解决您的问题。创建提供MockedMyClass实例的夹具应该很简单。

@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
Run Code Online (Sandbox Code Playgroud)