首页 > 解决方案 > 我可以修补 Python 的断言以获取 py.test 提供的输出吗?

问题描述

Pytest 的失败断言输出比 Python 中的默认输出更具信息性和实用性。我想在正常运行我的 Python 程序时利用它,而不仅仅是在执行测试时。有没有办法从我的脚本中覆盖 Python 的assert行为以使用 pytest 来打印堆栈跟踪,同时仍将我的程序运行为python script/pytest_assert.py

示例程序

def test_foo():
  foo = 12
  bar = 42
  assert foo == bar

if __name__ == '__main__':
  test_foo()

$ python script/pytest_assert.py

Traceback (most recent call last):
  File "script/pytest_assert.py", line 8, in <module>
    test_foo()
  File "script/pytest_assert.py", line 4, in test_foo
    assert foo == bar
AssertionError

$ pytest script/pytest_assert.py

======================== test session starts ========================
platform linux -- Python 3.5.3, pytest-3.3.2, py-1.5.2, pluggy-0.6.0
rootdir: /usr/local/google/home/danijar, inifile:
collected 1 item                                                    

script/pytest_assert.py F                                     [100%]

============================= FAILURES ==============================
_____________________________ test_foo ______________________________

    def test_foo():
      foo = 12
      bar = 42
>     assert foo == bar
E     assert 12 == 42

script/pytest_assert.py:4: AssertionError
===================== 1 failed in 0.02 seconds =====================

期望的结果

$ python script/pytest_assert.py

Traceback (most recent call last):
  File "script/pytest_assert.py", line 8, in <module>
    test_foo()

    def test_foo():
      foo = 12
      bar = 42
>     assert foo == bar
E     assert 12 == 42

script/pytest_assert.py:4: AssertionError

进度更新

我得到的最接近的是this,但它仅适用于该函数中的断言并发送垃圾邮件跟踪:

import ast
import inspect

from _pytest import assertion


def test_foo():
  foo = []
  foo.append(13)
  foo = foo[-1]
  bar = 42
  assert foo == bar, 'message'


if __name__ == '__main__':
  tree = ast.parse(inspect.getsource(test_foo))
  assertion.rewrite.rewrite_asserts(tree)
  code = compile(tree, '<name>', 'exec')
  ns = {}
  exec(code, ns)
  ns[test_foo.__name__]()

$ python script/pytest_assert.py
Traceback (most recent call last):
  File "script/pytest_assert.py", line 21, in <module>
    ns[test_foo.__name__]()
  File "<name>", line 6, in test_foo
AssertionError: message
assert 13 == 42

标签: pythonpython-3.xdebuggingpytestassert

解决方案


免责声明

虽然肯定有一种方法可以重用pytest代码以所需格式打印回溯,但您需要使用的东西不是公共 API 的一部分,因此生成的解决方案将太脆弱,需要调用不相关的pytest代码(用于初始化目的) 并且可能会中断包更新。最好的办法是重写关键部分,以pytest代码为例。

笔记

基本上,下面的概念验证代码做了三件事:

  1. 用自定义替换默认值sys.excepthook:这是更改默认回溯格式所必需的。例子:

    import sys
    
    orig_hook = sys.excepthook
    
    def myhook(*args):
        orig_hook(*args)
        print('hello world')
    
    if __name__ == '__main__':
        sys.excepthook = myhook
        raise ValueError()
    

    将输出:

    Traceback (most recent call last):
      File "example.py", line 11, in <module>
        raise ValueError()
    ValueError
    hello world
    
  2. 而不是hello world,将打印格式化的异常信息。我们ExceptionInfo.getrepr()为此使用。

  3. 要访问断言中的附加信息,请pytest重写语句(您可以在这篇旧文章assert中获得一些关于重写后它们的外观的粗略信息)。为此,请注册PEP 302中指定的自定义导入挂钩。钩子是最有问题的部分,因为它与对象紧密耦合,我还注意到一些模块导入会导致问题(我猜它不会失败只是因为在注册钩子时模块已经导入;将尝试编写在运行中重现问题并创建新问题的测试)。因此,我建议编写一个自定义导入钩子来调用pytestConfigpytestpytestAssertionRewriter. 这个 AST tree walker 类是断言重写中必不可少的部分,但AssertionRewritingHook并不那么重要。

代码

so-51839452
├── hooks.py
├── main.py
└── pytest_assert.py

hooks.py

import sys

from pluggy import PluginManager
import _pytest.assertion.rewrite
from _pytest._code.code import ExceptionInfo
from _pytest.config import Config, PytestPluginManager


orig_excepthook = sys.excepthook

def _custom_excepthook(type, value, tb):
    orig_excepthook(type, value, tb)  # this is the original traceback printed
    # preparations for creation of pytest's exception info
    tb = tb.tb_next  # Skip *this* frame
    sys.last_type = type
    sys.last_value = value
    sys.last_traceback = tb

    info = ExceptionInfo(tup=(type, value, tb, ))

    # some of these params are configurable via pytest.ini
    # different params combination generates different output
    # e.g. style can be one of long|short|no|native
    params = {'funcargs': True, 'abspath': False, 'showlocals': False,
              'style': 'long', 'tbfilter': False, 'truncate_locals': True}
    print('------------------------------------')
    print(info.getrepr(**params))  # this is the exception info formatted
    del type, value, tb  # get rid of these in this frame


def _install_excepthook():
    sys.excepthook = _custom_excepthook


def _install_pytest_assertion_rewrite():
    # create minimal config stub so AssertionRewritingHook is happy
    pluginmanager = PytestPluginManager()
    config = Config(pluginmanager)
    config._parser._inidict['python_files'] = ('', '', [''])
    config._inicache = {'python_files': None, 'python_functions': None}
    config.inicfg = {}

    # these modules _have_ to be imported, or AssertionRewritingHook will complain
    import py._builtin
    import py._path.local
    import py._io.saferepr

    # call hook registration
    _pytest.assertion.install_importhook(config)

# convenience function
def install_hooks():
    _install_excepthook()
    _install_pytest_assertion_rewrite()

main.py

调用后hooks.install_hooks()main.py将有修改的回溯打印。调用后导入的每个模块install_hooks()都会在导入时重写断言。

from hooks import install_hooks

install_hooks()

import pytest_assert


if __name__ == '__main__':
    pytest_assert.test_foo()

pytest_assert.py

def test_foo():
    foo = 12
    bar = 42
    assert foo == bar

示例输出

$ python main.py
Traceback (most recent call last):
  File "main.py", line 9, in <module>
    pytest_assert.test_foo()
  File "/Users/hoefling/projects/private/stackoverflow/so-51839452/pytest_assert.py", line 4, in test_foo
    assert foo == bar
AssertionError
------------------------------------
def test_foo():
        foo = 12
        bar = 42
>       assert foo == bar
E       AssertionError

pytest_assert.py:4: AssertionError

总结

我会写一个自己的版本AssertionRewritingHook,没有整个不相关的pytest东西。然而,AssertionRewriter看起来非常可重用;虽然它需要一个Config实例,但它只用于警告打印,可以留给None.

一旦你有了它,编写你自己的函数来正确格式化异常,替换sys.excepthook你就完成了。


推荐阅读