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

Is it possible to change PyTest's assert statement behaviour in Python

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

我还想添加一些东西,比如如果有断言错误,那么测试用例应该暂停,用户可以随时恢复。

我不知道该怎么做

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

import pytest
def test_abc():
    a = 10
    assert a == 10, "some error message"

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)

展望未来,我必须将每个带有此函数的断言语句更改为

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

这对我来说太费力了,因为我必须在我使用过 assert 的数千个地方进行更改。在 pytest

中有没有简单的方法可以做到这一点

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

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

编辑:感谢您的回答。现在稍微提前扩展这个问题。如果我想改变 assert 的行为怎么办?目前,当存在断言错误时,测试用例会退出。如果我想选择是否需要在特定断言失败时退出测试用例怎么办。我不想像上面提到的那样编写自定义断言函数,因为这样我必须在很多地方进行更改

您可以使用 pytest --pdb.

完全不需要任何代码修改就可以实现您想要的效果

以你为例:

import pytest
def test_abc():
    a = 9
    assert a == 10, "some error message"

运行 与 --pdb:

py.test --pdb
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)

一旦测试失败,您可以使用内置 python 调试器对其进行调试。如果您已完成调试,则可以 continue 进行其余测试。

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

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

  1. 您可以使用 --pdb option 告诉 pytest 在出现错误时让您进入调试器。

  2. 你可以定义下面的装饰器,用它装饰各个相关的测试函数。 (除了记录消息,您还可以启动 pdb.post_mortem at this point, or even an interactive code.interact with the locals of the frame where the exception originated, as described in this answer。)

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"

  1. 如果您不想手动修饰每个测试函数,您可以改为定义一个检查 sys.last_value:
  2. 的自动夹具
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)

一个简单的解决方案,如果您愿意使用 Visual Studio 代码,可以使用 conditional breakpoints.

这将允许您设置断言,例如:

import pytest
def test_abc():
    a = 10
    assert a == 10, "some error message"

然后在你的断言行中添加一个条件断点,它只会在你的断言失败时中断:

您正在使用 pytest,它为您提供了充足的选项来与失败的测试进行交互。它为您提供了命令行选项和几个钩子来实现这一点。我将解释如何使用每一个以及您可以在何处进行自定义以满足您的特定调试需求。

我还将介绍更多奇特的选项,如果您真的觉得必须的话,这些选项可以让您完全跳过特定的断言。

处理异常,而不是断言

请注意,失败的测试通常不会停止 pytest;仅当您启用 explicitly tell it to exit after a certain number of failures. Also, tests fail because an exception is raised; assert raises AssertionError 时,但这不是唯一会导致测试失败的异常!您想要控制异常的处理方式,而不是改变 assert.

但是,失败的断言 结束单个测试。那是因为一旦在 try...except 块之外引发异常,Python 就会展开当前函数框架,并且无法返回。

根据您对 _assertCustom() 尝试重新 运行 断言的描述判断,我认为这不是您想要的,但我将在下面进一步讨论您的选择。

Post-使用 pdb 在 pytest 中进行 mortem 调试

对于在调试器中处理失败的各种选项,我将从 --pdb command-line switch 开始,它会在测试失败时打开标准调试提示(为简洁起见省略了输出):

$ 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
[ ... ]

有了这个开关,当测试失败时,pytest 会启动一个 post-mortem debugging session。这基本上正是您想要的;在测试失败时停止代码并打开调试器以查看测试状态。您可以与测试的局部变量、全局变量以及堆栈中每一帧的局部变量和全局变量进行交互。

这里 pytest 让你完全控制在这一点之后是否退出:如果你使用 q 退出命令,那么 pytest 也会退出 运行,使用 c for continue 将 return 控制到 pytest 并执行下一个测试。

使用替代调试器

您没有为此绑定到 pdb 调试器;您可以使用 --pdbcls 开关设置不同的调试器。任何 pdb.Pdb() compatible implementation would work, including the IPython debugger implementation, or most other Python debuggers (the pudb debugger requires the -s switch is used, or a special plugin)。开关需要一个模块和 class,例如要使用 pudb 你可以使用:

$ pytest -s --pdb --pdbcls=pudb.debugger:Debugger

您可以使用此功能围绕 Pdb 编写您自己的包装器 class,如果您对特定故障不感兴趣,则立即 returns。pytest 使用 Pdb() 完全像 pdb.post_mortem() does:

p = Pdb()
p.reset()
p.interaction(None, t)

这里,t是一个traceback object。当p.interaction(None, t)returns,pytest继续下一次测试,除非p.quitting设置为True(at然后 pytest 退出)。

这是一个示例实现,打印出我们正在拒绝调试并立即 returns,除非测试引发 ValueError,另存为 demo/custom_pdb.py:

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)

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

$ 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)

以上自省sys.last_type判断失败是否为'interesting'.

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

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

下一级是 pytest debugging and interaction hooks; these are hook points for behaviour customisations, to replace or enhance how pytest normally handles things like handling an exception or entering the debugger via pdb.set_trace() or breakpoint()(Python 3.7 或更高版本)。

这个钩子的内部实现也负责打印上面的 >>> entering PDB >>> 横幅,所以使用这个钩子来防止调试器 运行ning 意味着你不会在全部。您可以拥有自己的挂钩,然后在测试失败 'interesting' 时委托给原始挂钩,因此过滤测试失败 独立 您正在使用的调试器!您可以通过 accessing it by name 访问内部实现;这个的内部钩子插件被命名为 pdbinvoke。为了防止它 运行ning 你需要 注销 它但保存一个引用我们可以根据需要直接调用它。

这是此类挂钩的示例实现;你可以把它放在 any of the locations plugins are loaded from;我把它放在 demo/conftest.py:

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)

上述插件使用内部 TerminalReporter plugin 将行写到终端;这使得在使用默认的紧凑测试状态格式时输出更清晰,并且即使启用了输出捕获也允许您将内容写入终端。

该示例通过另一个挂钩 pytest_configure(), but making sure it runs late enough (using @pytest.hookimpl(trylast=True)) to be able to un-register the internal pdbinvoke plugin. When the hook is called, the example tests against the call.exceptinfo object; you can also check the node or the reportpytest_exception_interact 挂钩注册插件对象。

将上述示例代码放在 demo/conftest.py 中,test_ham 测试失败将被忽略,只有 test_spam 测试失败会引发 ValueError,导致调试提示打开:

$ 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) 

重申一下,上述方法具有额外的优势,您可以将其与任何使用 pytest 的调试器结合使用,包括 pudb,或 IPython调试器:

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

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

请注意,特定的 pytest 调试器插件(例如 pytest-pudbpytest-pycharm)会注册它们自己的 pytest_exception_interact hooksp。更完整的实现必须遍历插件管理器中的所有插件以自动覆盖任意插件,使用 config.pluginmanager.list_name_pluginhasattr() 来测试每个插件。

让失败完全消失

虽然这使您可以完全控制失败的测试调试,但即使您选择不为给定测试打开调试器,它仍然会将测试保留为 失败。如果你想让失败完全消失,你可以使用不同的钩子:pytest_runtest_call().

当 pytest 运行s 测试时,它将 运行 通过上面的钩子进行测试,预计 return None 或引发异常。由此创建报告,可选地创建日志条目,如果测试失败,则调用上述 pytest_exception_interact() 挂钩。所以你需要做的就是改变这个钩子产生的结果;而不是异常,它根本不应该 return 任何东西。

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

outcome = yield

在您的挂钩包装器实现中,您可以访问 hook result, including the test exception via outcome.excinfo. This attribute is set to a tuple of (type, instance, traceback) if an exception was raised in the test. Alternatively, you could call outcome.get_result() 并使用标准 try...except 处理。

那么如何让失败的测试通过呢?您有 3 个基本选项:

  • 您可以通过在包装器中调用 pytest.xfail() 将测试标记为 预期 失败。
  • 您可以通过调用 pytest.skip().[= 将项目标记为 skipped,这假装测试从一开始就不是 运行 288=]
  • 您可以使用 outcome.force_result() method; 删除异常。在这里将结果设置为一个空列表(意思是:注册的钩子只产生 None),并且异常被完全清除。

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

这是一个示例实现,它将未引发 ValueError 的失败测试标记为 skipped:

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")

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

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

我使用了 -r a 标志来更清楚地表明 test_ham 现在被跳过了。

如果将 pytest.skip() 调用替换为 pytest.xfail("[XFAIL] ignoring everything but ValueError"),测试将被标记为预期失败:

[ ... ]
XFAIL demo/test_foo.py::test_ham
  reason: [XFAIL] ignoring everything but ValueError
[ ... ]

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

$ pytest -v demo/test_foo.py  # verbose to see individual PASSED entries
[ ... ]
demo/test_foo.py::test_ham PASSED                                        [ 50%]

您认为哪一个最适合您的用例取决于您。对于 skip()xfail(),我模仿了标准消息格式(前缀为 [NOTRUN][XFAIL]),但您可以自由使用任何您想要的其他消息格式。

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

改变个别断言语句

如果您想在测试 中更改assert 测试,那么您需要做更多的工作。是的,这 技术上 是可能的,但只有重写 Python 将在编译时 执行的代码 .

当你使用pytest时,这实际上已经完成。 Pytest rewrites assert statements to give you more context when your asserts fail; see this blog post for a good overview of exactly what is being done, as well as the _pytest/assertion/rewrite.py source code. Note that that module is over 1k lines long, and requires that you understand how Python's abstract syntax trees 工作。如果这样做,您 可以 monkeypatch 该模块以在那里添加您自己的修改,包括用 try...except AssertionError: 处理程序围绕 assert

但是,您不能选择性地禁用或忽略断言,因为后续语句很容易依赖于跳过的状态(特定对象排列、变量集等) assert 是用来防范的。如果断言测试 foo 不是 None,那么后面的断言依赖于 foo.bar 存在,那么你只需将 运行 变成 AttributeError,等等。如果你需要走这条路,一定要坚持重新提出例外。

我不打算在这里详细介绍重写 asserts,因为我认为这不值得追求,考虑到所涉及的工作量,并且 post -mortem 调试让您可以在断言失败时访问测试状态 anyway.

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

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

Python 调试器实际上允许您使用 j / jump command 跳过语句 。如果您预先知道特定断言失败,您可以使用它来绕过它。您可以 运行 使用 --trace 进行测试,这会在每次测试 开始时打开调试器 ,然后发出 j <line after assert> 以跳过它调试器在断言之前暂停。

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

  • 使用 pytest_testrun_call() 钩子捕获 AssertionError 异常
  • 从回溯中提取行 'offending' 行号,也许通过一些源代码分析确定执行成功跳转所需的断言前后的行号
  • 运行再次测试 ,但这次使用 Pdb subclass 在assert,并在遇到断点时自动执行跳转到第二个,然后是 c 继续。

或者,您可以为测试中发现的每个 assert 自动设置断点,而不是等待断言失败(再次使用源代码分析,您可以简单地提取 [=109= 的行号] 测试的 AST 中的节点),使用调试器脚本命令执行断言测试,并使用 jump 命令跳过断言本身。您必须做出权衡; 运行 调试器下的所有测试(这很慢,因为解释器必须为每个语句调用跟踪函数)或仅将其应用于失败的测试并支付重新运行从中重新测试这些测试的代价刮。

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