是否可以在Python中更改PyTest的assert语句行为

Nit*_*esh 18 python testing assert pytest

我正在使用Python断言语句来匹配实际和预期的行为。我对这些没有控制权,好像有一个错误测试用例中止了一样。我想控制断言错误,并要定义是否要在失败断言时中止测试用例。

我还想添加一些类似的内容,如果存在断言错误,则应该暂停测试用例,并且用户可以随时恢复。

我不知道该怎么做

代码示例,我们在这里使用pytest

import pytest
def test_abc():
    a = 10
    assert a == 10, "some error message"
Run Code Online (Sandbox Code Playgroud)

Below is my expectation

当assert抛出assertionError时,我应该可以选择暂停测试用例,并且可以调试并稍后恢复。对于暂停和恢复,我将使用tkinter模块。我将做一个断言功能如下

import tkinter
import tkinter.messagebox

top = tkinter.Tk()

def _assertCustom(assert_statement, pause_on_fail = 0):
    #assert_statement will be something like: assert a == 10, "Some error"
    #pause_on_fail will be derived from global file where I can change it on runtime
    if pause_on_fail == 1:
        try:
            eval(assert_statement)
        except AssertionError as e:
            tkinter.messagebox.showinfo(e)
            eval (assert_statement)
            #Above is to raise the assertion error again to fail the testcase
    else:
        eval (assert_statement)
Run Code Online (Sandbox Code Playgroud)

展望未来,我必须使用此函数更改每个断言语句

import pytest
def test_abc():
    a = 10
    # Suppose some code and below is the assert statement 
    _assertCustom("assert a == 10, 'error message'")
Run Code Online (Sandbox Code Playgroud)

这对我来说是太多的工作,因为我必须在使用assert的数千个地方进行更改。有没有简单的方法可以做到这一点pytest

Summary:我需要一些可以在失败时暂停测试用例,然后在调试后恢复的东西。我知道tkinter,这就是我使用它的原因。任何其他想法都将受到欢迎

Note:以上代码尚未经过测试。也可能有小的语法错误

编辑:感谢您的答案。现在将这个问题向前扩展一点。如果我想更改assert的行为该怎么办。当前,当存在断言错误时,测试用例退出。如果我想选择是否需要在特定断言失败时退出测试用例,该怎么办?我不想编写如上所述的自定义断言函数,因为这种方式我必须在许多地方进行更改

Mar*_*ers 23

您正在使用pytest,这为您提供了与失败的测试进行交互的充足选项。它为您提供了命令行选项和一些挂钩,以实现此目的。我将说明如何使用每种方法以及在何处可以进行自定义以满足您特定的调试需求。

如果确实需要,我还将介绍更多奇特的选项,这些选项将允许您完全跳过特定的断言。

处理异常,而不是断言

请注意,失败的测试通常不会停止pytest;仅当您启用显式告诉它在一定数量的失败后退出时,才可以。同样,测试失败是因为引发了异常。assert引发,AssertionError但这不是唯一会导致测试失败的异常!您想控制异常的处理方式,而不是alter assert

但是,失败的断言终止单个测试。这是因为一旦在try...except块外引发了异常,Python就会展开当前函数框架,并且没有任何回溯了。

从您对_assertCustom()尝试重新运行该断言的描述来看,我认为这不是您想要的,但是尽管如此,我将进一步讨论您的选择。

使用PDB在pytest中进行事后调试

对于处理调试器中的故障的各种选项,我将--pdb从命令行开关开始,该开关在测试失败(为简洁起见而省略输出)时打开标准调试提示:

$ mkdir demo
$ touch demo/__init__.py
$ cat << EOF > demo/test_foo.py
> def test_ham():
>     assert 42 == 17
> def test_spam():
>     int("Vikings")
> EOF
$ pytest demo/test_foo.py --pdb
[ ... ]
test_foo.py:2: AssertionError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /.../demo/test_foo.py(2)test_ham()
-> assert 42 == 17
(Pdb) q
Exit: Quitting debugger
[ ... ]
Run Code Online (Sandbox Code Playgroud)

使用此开关,当测试失败时,pytest启动事后调试会话。本质上,这正是您想要的。在测试失败时停止代码并打开调试器以查看测试状态。您可以与测试的局部变量,全局变量以及堆栈中每个框架的局部变量和全局变量进行交互。

在这里pytest可以让您完全控制是否在此之后退出:如果使用qquit命令,则pytest也会退出运行,使用cfor continue将控制权返回pytest并执行下一个测试。

使用替代调试器

您不必为此受pdb调试器的约束;您可以使用--pdbcls开关设置其他调试器。任何pdb.Pdb()兼容的实现都可以使用,包括IPython调试器实现大多数其他Python调试器pudb调试器要求使用该-s开关或特殊的插件)。开关带有一个模块和类,例如要使用,pudb您可以使用:

$ pytest -s --pdb --pdbcls=pudb.debugger:Debugger
Run Code Online (Sandbox Code Playgroud)

您可以使用此功能来编写自己的包装类各地Pdb,简单地立即返回,如果特定的故障是不是你感兴趣的pytest用途Pdb()一样准确pdb.post_mortem()

p = Pdb()
p.reset()
p.interaction(None, t)
Run Code Online (Sandbox Code Playgroud)

这里t是一个回溯对象。当p.interaction(None, t)返回时,pytest继续进行下一个测试,除非 p.quitting被设置为True(在该点处pytest然后退出)。

这是一个示例实现,可以打印出我们拒绝调试并立即返回的结果,除非将test提升为ValueError,另存为demo/custom_pdb.py

p = Pdb()
p.reset()
p.interaction(None, t)
Run Code Online (Sandbox Code Playgroud)

当我在上面的演示中使用它时,这是输出(同样,为简洁起见,省略了它):

import pdb, sys

class CustomPdb(pdb.Pdb):
    def interaction(self, frame, traceback):
        if sys.last_type is not None and not issubclass(sys.last_type, ValueError):
            print("Sorry, not interested in this failure")
            return
        return super().interaction(frame, traceback)
Run Code Online (Sandbox Code Playgroud)

上面的内省sys.last_type确定了故障是否“有趣”。

但是,除非您想使用tkInter或类似的东西编写自己的调试器,否则我不能真正推荐此选项。请注意,这是一项艰巨的任务。

过滤失败;选择并选择何时打开调试器

下一阶段是pytest 调试和交互挂钩;这些是行为自定义的挂钩点,以替换或增强pytest通常如何处理诸如处理异常或通过pdb.set_trace()breakpoint()(Python 3.7或更高版本)进入调试器的方式。

该挂钩的内部实现也负责打印>>> entering PDB >>>上方的横幅,因此使用此挂钩来阻止调试器运行意味着您根本看不到此输出。您可以拥有自己的钩子,然后在测试失败“很有趣”时委托给原始钩子,因此可以独立于所使用的调试器来过滤测试失败!您可以通过按名称访问内部实现来访问它;内部挂钩插件的名称为pdbinvoke。为了阻止它运行,您需要注销它,但是要保存参考,我们可以根据需要直接调用它。

这是这种钩子的示例实现;您可以将其放在插件从中加载的任何位置;我把它放在demo/conftest.py

$ pytest test_foo.py -s --pdb --pdbcls=demo.custom_pdb:CustomPdb
[ ... ]
    def test_ham():
>       assert 42 == 17
E       assert 42 == 17

test_foo.py:2: AssertionError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Sorry, not interested in this failure
F
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> traceback >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

    def test_spam():
>       int("Vikings")
E       ValueError: invalid literal for int() with base 10: 'Vikings'

test_foo.py:4: ValueError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /.../test_foo.py(4)test_spam()
-> int("Vikings")
(Pdb)
Run Code Online (Sandbox Code Playgroud)

上面的插件使用内部TerminalReporter插件向终端写线;使用默认的紧凑测试状态格式时,这可以使输出更整洁,并且即使启用了输出捕获,也可以将内容写入终端。

该示例pytest_exception_interact通过另一个钩子向钩子注册了插件对象pytest_configure(),但要确保它运行得足够晚(使用@pytest.hookimpl(trylast=True)),以能够注销内部pdbinvoke插件。调用该钩子时,该示例将针对该call.exceptinfo对象进行测试;您也可以检查节点报告

与代替上述示例代码中demo/conftest.py,该test_ham测试失败,则忽略,仅test_spam测试失败,这引起了ValueError,结果在调试提示开口:

import pytest

@pytest.hookimpl(trylast=True)
def pytest_configure(config):
    # unregister returns the unregistered plugin
    pdbinvoke = config.pluginmanager.unregister(name="pdbinvoke")
    if pdbinvoke is None:
        # no --pdb switch used, no debugging requested
        return
    # get the terminalreporter too, to write to the console
    tr = config.pluginmanager.getplugin("terminalreporter")
    # create or own plugin
    plugin = ExceptionFilter(pdbinvoke, tr)

    # register our plugin, pytest will then start calling our plugin hooks
    config.pluginmanager.register(plugin, "exception_filter")

class ExceptionFilter:
    def __init__(self, pdbinvoke, terminalreporter):
        # provide the same functionality as pdbinvoke
        self.pytest_internalerror = pdbinvoke.pytest_internalerror
        self.orig_exception_interact = pdbinvoke.pytest_exception_interact
        self.tr = terminalreporter

    def pytest_exception_interact(self, node, call, report):
        if not call.excinfo. errisinstance(ValueError):
            self.tr.write_line("Sorry, not interested!")
            return
        return self.orig_exception_interact(node, call, report)
Run Code Online (Sandbox Code Playgroud)

重申一下,上述方法的另一个优点是,您可以将其与任何与pytest一起使用的调试器(包括pudb或IPython调试器)结合使用:

$ pytest demo/test_foo.py --pdb
[ ... ]
demo/test_foo.py F
Sorry, not interested!

demo/test_foo.py F
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> traceback >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

    def test_spam():
>       int("Vikings")
E       ValueError: invalid literal for int() with base 10: 'Vikings'

demo/test_foo.py:4: ValueError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /.../demo/test_foo.py(4)test_spam()
-> int("Vikings")
(Pdb) 
Run Code Online (Sandbox Code Playgroud)

它还具有有关正在运行什么测试(通过node参数)和直接访问引发的异常(通过call.excinfo ExceptionInfo实例)的更多上下文。

请注意,特定的pytest调试器插件(例如pytest-pudbpytest-pycharm)注册了自己的pytest_exception_interacthooksp。一个更完整的实现将必须遍历plugin-manager中的所有插件,以自动覆盖,使用config.pluginmanager.list_name_pluginhasattr()测试每个插件的任意插件。

使失败彻底消失

尽管这可以完全控制失败的测试调试,但是即使您选择不为给定测试打开调试器,这仍然会使测试失败。如果您想使故障完全消失,则可以使用其他钩子:pytest_runtest_call()

pytest运行测试时,它将通过上面的钩子运行测试,该钩子将返回None或引发异常。由此创建一个报告,可以选择创建一个日志条目,如果测试失败,pytest_exception_interact()则调用上述钩子。因此,您所需要做的就是更改此挂钩产生的结果。除了异常之外,它根本不返回任何内容。

最好的方法是使用钩子包装。挂钩包装器不必执行实际工作,而是有机会更改挂钩结果。您所要做的就是添加以下行:

outcome = yield
Run Code Online (Sandbox Code Playgroud)

在挂钩包装器实现中,您可以通过访问挂钩结果,包括测试异常outcome.excinfo。如果在测试中引发异常,则将此属性设置为(类型,实例,回溯)的元组。或者,您可以调用outcome.get_result()并使用标准try...except处理。

那么,如何通过不合格的测试?您有3个基本选项:

  • 您可以通过调用包装器将测试标记为预期的失败pytest.xfail()
  • 您可以通过调用将项目标记为已跳过,从而假装从未进行过测试pytest.skip()
  • 您可以使用outcome.force_result()方法删除异常;此处将结果设置为一个空列表(表示:已注册的钩子只产生了None),并且异常完全清除。

您使用什么取决于您。确保确保先检查跳过的测试和预期失败的测试的结果,因为您无需像测试失败一样处理这些情况。您可以通过pytest.skip.Exception和访问这些选项引发的特殊异常pytest.xfail.Exception

这是一个示例实现,将未引发的失败测试标记ValueError跳过

$ pytest demo/test_foo.py --pdb --pdbcls=IPython.core.debugger:Pdb
[ ... ]
demo/test_foo.py F
Sorry, not interested!

demo/test_foo.py F
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> traceback >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

    def test_spam():
>       int("Vikings")
E       ValueError: invalid literal for int() with base 10: 'Vikings'

demo/test_foo.py:4: ValueError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /.../demo/test_foo.py(4)test_spam()
      1 def test_ham():
      2     assert 42 == 17
      3 def test_spam():
----> 4     int("Vikings")

ipdb>
Run Code Online (Sandbox Code Playgroud)

放入时conftest.py,输出变为:

outcome = yield
Run Code Online (Sandbox Code Playgroud)

我使用该-r a标志使它变得更清晰,test_ham现在已被跳过。

如果更换pytest.skip()电话用pytest.xfail("[XFAIL] ignoring everything but ValueError"),测试被标记为预期故障:

import pytest

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item):
    outcome = yield
    try:
        outcome.get_result()
    except (pytest.xfail.Exception, pytest.skip.Exception, pytest.exit.Exception):
        raise  # already xfailed,  skipped or explicit exit
    except ValueError:
        raise  # not ignoring
    except (pytest.fail.Exception, Exception):
        # turn everything else into a skip
        pytest.skip("[NOTRUN] ignoring everything but ValueError")
Run Code Online (Sandbox Code Playgroud)

并使用将其outcome.force_result([])标记为已通过:

$ pytest -r a demo/test_foo.py
============================= test session starts =============================
platform darwin -- Python 3.8.0, pytest-3.10.0, py-1.7.0, pluggy-0.8.0
rootdir: ..., inifile:
collected 2 items

demo/test_foo.py sF                                                      [100%]

=================================== FAILURES ===================================
__________________________________ test_spam ___________________________________

    def test_spam():
>       int("Vikings")
E       ValueError: invalid literal for int() with base 10: 'Vikings'

demo/test_foo.py:4: ValueError
=========================== short test summary info ============================
FAIL demo/test_foo.py::test_spam
SKIP [1] .../demo/conftest.py:12: [NOTRUN] ignoring everything but ValueError
===================== 1 failed, 1 skipped in 0.07 seconds ======================
Run Code Online (Sandbox Code Playgroud)

由您决定最适合您的用例的是您。对于skip()xfail()我模仿了标准消息格式(以[NOTRUN]或前缀[XFAIL]),但是您可以自由使用所需的任何其他消息格式。

在这三种情况下,pytest都不会为您使用此方法更改了结果的测试打开调试器。

更改单个断言语句

如果你想改变assert测试的测试范围内,那么你是在和自己的一大堆更多的工作。是的,这在技术上是可行的,但是只能通过重写Python将在编译时执行的代码来实现。

使用时pytest,实际上已经完成了当你的断言失败时, Pytest 重写assert语句给你更多的上下文 ; 请参阅此博客文章,以详细了解正在执行的工作以及_pytest/assertion/rewrite.py源代码。请注意,该模块的长度超过1k行,并且要求您了解Python的抽象语法树的工作方式。如果这样做,您可以对该模块进行猴子修补,以在其中添加您自己的修改,包括assert使用try...except AssertionError:处理程序将其包围。

但是,您不能只是选择性地禁用或忽略断言,因为后续语句很容易取决于跳过的断言旨在防止的状态(特定的对象排列,变量集等)。如果一个断言测试foo不是None,那么后面的一个断言依赖于foo.bar存在,那么您将碰到AttributeError那里,等等。如果您需要走这条路,请坚持重新引发异常。

我不打算在asserts这里更详细地进行重写,因为我认为这不值得追求,没有涉及到大量的工作,并且事后调试使您可以访问测试的状态。断言点仍然失败。

请注意,如果您确实想执行此操作,则无需使用eval()(无论如何它assert都是行不通的,它是一条语句,因此您需要使用它exec()),也不必运行两次断言(如果在声明中使用的表达式更改了状态,可能会导致问题。您应该将ast.Assert节点嵌入节点内ast.Try,并附加一个使用空ast.Raise节点的except处理程序,以重新引发捕获的异常。

使用调试器跳过断言语句。

Python调试器实际上允许您使用/ 命令跳过语句。如果你知道了前面一个特定的断言失败,你可以用它来绕过它。您可以使用进行测试,在每次测试开始时都会打开调试器,然后在调试器在断言之前暂停时发出a 来跳过调试器。jjump--tracej <line after assert>

您甚至可以自动执行此操作。使用以上技术,您可以构建一个自定义调试器插件,该插件

  • 使用pytest_testrun_call()钩子捕获AssertionError异常
  • 从回溯中提取行“违规”行号,也许通过一些源代码分析来确定执行成功跳转所需的断言前后的行号
  • 再次运行测试,但这一次使用Pdb子类,该子类在断言之前的行上设置断点,并在命中断点时自动执行到第二个的跳转,然后c继续。

或者,您可以自动为assert测试中找到的每个断点设置断点(而不是等待声明失败)(再次使用源代码分析,您可以为ast.Assert测试的AST 琐碎提取节点的行号),执行断言的测试使用调试器脚本命令,并使用该jump命令跳过断言本身。您必须进行权衡;在调试器下运行所有​​测试(这很慢,因为解释器必须为每个语句调用跟踪函数),或者仅将其应用于失败的测试,并付出从头开始重新运行这些测试的代价。

这样的插件将需要大量工作来创建,我这里不会写一个例子,部分是因为它无论如何都不适合答案,部分是因为我认为这不值得。我只是打开调试器并手动进行跳转。断言失败表示测试本身或被测代码存在错误,因此您最好只专注于调试问题。


gnv*_*nvk 7

You can achieve exactly what you want without absolutely any code modification with pytest --pdb.

With your example:

import pytest
def test_abc():
    a = 9
    assert a == 10, "some error message"
Run Code Online (Sandbox Code Playgroud)

Run with --pdb:

py.test --pdb
Run Code Online (Sandbox Code Playgroud)
collected 1 item

test_abc.py F
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> traceback >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

    def test_abc():
        a = 9
>       assert a == 10, "some error message"
E       AssertionError: some error message
E       assert 9 == 10

test_abc.py:4: AssertionError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /private/tmp/a/test_abc.py(4)test_abc()
-> assert a == 10, "some error message"
(Pdb) p a
9
(Pdb)
Run Code Online (Sandbox Code Playgroud)

As soon as a test fails, you can debug it with the builtin python debugger. If you're done debugging, you can continue with the rest of the tests.


Uri*_*nta 5

如果您使用的是 PyCharm,那么您可以添加一个异常断点以在断言失败时暂停执行。选择查看断点 (CTRL-SHIFT-F8) 并为 AssertionError 添加触发异常处理程序。请注意,这可能会减慢测试的执行速度。

否则,如果您不介意在每个失败的测试结束时(就在它出错之前)而不是在断言失败时暂停,那么您有几个选择。但是请注意,此时可能已经运行了各种清理代码,例如关闭在测试中打开的文件。可能的选项是:

  1. 您可以使用--pdb 选项告诉 pytest 在出现错误时将您放入调试器。

  2. 您可以定义以下装饰器并用它装饰每个相关的测试函数。(除了记录消息之外,您还可以在此时启动pdb.post_mortem,或者甚至是交互式code.interact引发异常的帧的局部变量,如本答案所述。)

from functools import wraps

def pause_on_assert(test_func):
    @wraps(test_func)
    def test_wrapper(*args, **kwargs):
        try:
            test_func(*args, **kwargs)
        except AssertionError as e:
            tkinter.messagebox.showinfo(e)
            # re-raise exception to make the test fail
            raise
    return test_wrapper

@pause_on_assert
def test_abc()
    a = 10
    assert a == 2, "some error message"

Run Code Online (Sandbox Code Playgroud)
  1. 如果你不想手动装饰每个测试函数,你可以定义一个检查sys.last_value的自动夹具:
import sys

@pytest.fixture(scope="function", autouse=True)
def pause_on_assert():
    yield
    if hasattr(sys, 'last_value') and isinstance(sys.last_value, AssertionError):
        tkinter.messagebox.showinfo(sys.last_value)
Run Code Online (Sandbox Code Playgroud)