首页 > 解决方案 > 是否可以在 Python 中更改 PyTest 的断言语句行为

问题描述

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

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

我不知道该怎么做

代码示例,我们这里使用的是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'")

这对我来说太费力了,因为我必须在我使用过断言的数千个地方进行更改。有什么简单的方法可以做到这一点pytest

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

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

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

标签: pythontestingpytestassert

解决方案


您正在使用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
[ ... ]

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

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

使用替代调试器

您不必pdb为此绑定到调试器;--pdbcls您可以使用开关设置不同的调试器。任何pdb.Pdb()兼容的实现都可以工作,包括IPython 调试器实现,或大多数其他 Python 调试器pudb 调试器需要使用-s开关,或特殊插件)。开关采用模块和类,例如,pudb您可以使用:

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

您可以使用此功能编写自己的包装类Pdb,如果特定的失败不是您感兴趣的,则立即返回。pytest使用Pdb()完全pdb.post_mortem()一样

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

这里,t是一个回溯对象p.interaction(None, t)返回时,继续pytest下一个测试,除非 p.quitting设置为True(此时 pytest 退出)。

这是一个示例实现,它打印出我们拒绝调试并立即返回,除非测试引发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以确定失败是否“有趣”。

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

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

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

这个钩子的内部实现也负责打印>>> entering PDB >>>上面的横幅,所以使用这个钩子来阻止调试器运行意味着你根本看不到这个输出。您可以拥有自己的钩子,然后在测试失败“有趣”时委托给原始钩子,这样就可以独立于您使用的调试器过滤测试失败!您可以通过 name 访问它来访问内部实现;用于此的内部钩子插件名为pdbinvoke. 为了防止它运行,您需要取消注册它但保存一个引用,我们可以根据需要直接调用它。

这是这样一个钩子的示例实现;你可以把它放在插件加载的任何位置;我把它放在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插件向终端写入行;这使得使用默认紧凑测试状态格式时输出更清晰,并且即使启用了输出捕获,您也可以将内容写入终端。

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

使用上面的示例代码demo/conftest.pytest_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_interacthooksp。更完整的实现必须遍历插件管理器中的所有插件,以自动覆盖任意插件,使用config.pluginmanager.list_name_pluginhasattr()测试每个插件。

让失败完全消失

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

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

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

outcome = yield

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

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

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

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

这是一个示例实现,它将未引发的失败测试标记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")

当放入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%]

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

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

更改单个断言语句

如果你想在一个测试assert中改变测试,那么你就是在为自己做更多的工作。是的,这在技术上是可行的,但只能通过重写 Python 将在编译时执行的代码。

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

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

我不打算在asserts这里详细介绍重写,因为我认为这不值得追求,不考虑所涉及的工作量,并且通过事后调试,您可以访问测试的状态无论如何,断言失败点。

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

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

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

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

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

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

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


推荐阅读