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

2024-10-01 00:28:14 发布

您现在位置:Python中文网/ 问答频道 /正文

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

将给出以下结果:

====================================================================================================== 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

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

这是一个bug还是一个特性?我如何解决这个问题


Tags: pytestnonereturninitpytestvaluedef
2条回答

有两件事会影响对__del__的模拟:

  1. 一旦测试函数结束,方法的修补就会恢复

    这在monkeypatch API reference中提到,也可以从monkeypatch夹具本身的代码中看到,它在这里调用^{}方法:

    @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. this other answer中所述,调用__del__的时间(即,当对象被销毁和垃圾收集时)不是您可以保证或期望在测试函数引发AssertionError时发生的事情。只有在没有更多引用时才会调用它:

    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.

考虑到这两件事,发生的情况是__del__的monkeypatching在MyClass对象c最终被删除之前(当调用其__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: 

据我所知,函数是在hook_impl.function中调用的(请参见传递的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

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

  1. 当pytest捕获AssertionError时,它有效地添加了对本地c对象的引用,防止在函数结束时对其进行__del__-ed
  2. 然后,monkeypatching被撤消/恢复,而c仍然没有被垃圾收集
  3. 然后,pytest在所有测试函数运行后,最终报告它收集的所有错误(这将释放对c实例的引用,使其能够__del__
  4. 最后c__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

现在

How could I work around this issue?

与其依赖于最终删除对象的时间和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

Overriding destructors without calling their parents。由于派生类不调用父类“__del__,因此在测试期间不会调用它。它类似于monkeypatching,用其他东西替换该方法,但在这里__del__的定义在整个测试期间仍然是模拟的。MyClass的所有其他功能应该仍然可以从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__

在这里,我们看到销毁c只调用模拟的__del__(实际上在这里什么都不做)。没有更多的“破坏我的类”,希望能解决你的问题。创建一个提供MockedMyClass实例的fixture应该很简单

@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不保证为解释器退出时仍然存在的对象调用__del__()方法,并且还有一些其他含义,这些含义在官方文档中有更好的解释:

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

例如,如果要确保调用此方法,必须确保在解释器开始关闭之前对对象进行垃圾收集,例如使用pytest fixture等

相关问题 更多 >