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

时间:2019-10-10 11:35:53

标签: python testing pytest assert

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

我还想添加类似断言错误的内容,然后应该暂停测试用例,并且用户可以随时恢复。

我不知道该怎么做

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

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

Below is my expectation

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

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)

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

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的行为该怎么办。当前,当存在断言错误时,测试用例退出。如果我想选择是否需要在特定断言失败时退出测试用例,该怎么办?我不想编写如上所述的自定义断言函数,因为这样一来,我必须在位数上进行更改

4 个答案:

答案 0 :(得分:23)

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

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

处理异常,而不是断言

请注意,测试失败通常不会停止pytest;仅当您启用explicitly tell it to exit after a certain number of failures时。同样,测试失败是因为引发了异常。 assert引发AssertionError,但这不是导致测试失败的唯一例外!您想控制异常的处理方式,而不是更改assert

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

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

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

对于处理调试器中失败的各种选项,我将从--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 quit命令,则pytest也会退出运行,使用c继续可以将控制权返回给pytest然后执行下一个测试。

使用备用调试器

您不必为此受pdb调试器的约束;您可以使用--pdbcls开关设置其他调试器。任何pdb.Pdb() compatible实现都可以使用,包括IPython debugger implementationmost other Python debuggerspudb debugger要求使用-s开关,或者使用special plugin) 。交换机采用模块和类,例如要使用pudb,可以使用:

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

您可以使用此功能在Pdb周围编写您自己的包装器类,如果您对特定故障不感兴趣,则可以立即返回。pytest的使用Pdb()与{ {3}}:

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

在这里,tpdb.post_mortem() does。当p.interaction(None, t)返回时,pytest继续进行下一个测试,除非 p.quitting设置为True(此时pytest然后退出)。 / p>

这是一个示例实现,可以打印出我们拒绝调试并立即返回,除非测试引发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)

以上traceback object进行了内省,以确定失败是否“有趣”。

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

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

下一个升级是sys.last_type;这些是行为自定义的挂钩点,以替换或增强pytest正常处理诸如处理异常或通过pytest debugging and interaction hookspdb.set_trace()(Python 3.7或更高版本)进入调试器的方式。

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

这里是这种钩子的示例实现;您可以将其放在accessing it by name中;我把它放在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)

上述插件使用内部any of the locations plugins are loaded from来向终端写出行;使用默认的紧凑测试状态格式时,这可以使输出更整洁,并且即使启用了输出捕获,也可以将内容写入终端。

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

demo/conftest.py中放置了以上示例代码后,test_ham测试失败将被忽略,只有引发test_spam的{​​{1}}测试失败会导致调试提示打开:

ValueError

要重申一下,上述方法的另一个优点是,您可以将其与任何与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) 

它还具有有关正在运行什么测试(通过$ 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实例)的更多上下文。

请注意,特定的pytest调试器插件(例如ExceptionInfopytest-pudb)注册了自己的pytest-pycharm hooksp。一个更完整的实现将必须使用reportpytest_exception_interact来测试每个插件,从而自动遍历plugin-manager中的所有插件以覆盖任意插件。

使失败彻底消失

尽管这可以完全控制失败的测试调试,但即使您选择不为给定测试打开调试器,这仍然使测试保持为失败。如果您想使故障完全消失,则可以使用另一个钩子:config.pluginmanager.list_name_plugin

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

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

pytest_exception_interact()
在挂钩包装实现中

,您就可以访问hook wrapper,包括通过outcome = yield 进行的测试异常。如果在测试中引发异常,则将此属性设置为(类型,实例,回溯)的元组。另外,您可以调用hook result并使用标准的outcome.excinfo处理。

那么您如何使测试合格失败?您有3个基本选项:

  • 通过在包装器中调用outcome.get_result(),可以将测试标记为预期失败。
  • 您可以通过调用pytest.xfail()将该项目标记为已跳过,从而假装从未进行过测试。
  • 您可以使用pytest.skip()删除异常;将结果设置为此处的空列表(意味着:注册的钩子只产生try...except,什么也没有产生),并且异常完全清除。

您所使用的取决于您。确保确保先检查跳过的测试和预期失败的测试的结果,因为您无需像测试失败一样处理这些情况。您可以通过Nonepytest.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%] skip(),我模仿了标准消息格式(以xfail()[NOTRUN]为前缀),但是您可以自由使用所需的任何其他消息格式。

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

更改个别断言语句

如果您想在测试中更改[XFAIL]个测试 ,那么您将为自己做更多的工作。是的,这在技术上是可行的,但只能通过重写Python将在编译时执行的代码来实现。

当您使用assert时,实际上已经已完成。 Pytest outcome.force_result() method;请参阅rewrites assert statements to give you more context when your asserts fail,以详细了解正在执行的工作以及this blog post。请注意,该模块的长度超过1k行,并且要求您了解_pytest/assertion/rewrite.py source code的工作方式。如果这样做,您可以 对该模块进行猴子修补,以在其中添加您自己的修改,包括用pytest处理程序包围assert

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

在这里,我将不进一步详细介绍重写AttributeError,因为我认为这样做不值得,没有涉及大量的工作,并且事后调试使您可以访问断言失败无论如何的测试状态。

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

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

Python调试器实际上允许您使用Python's abstract syntax trees skip语句。如果您预先知道某个特定断言会失败,则可以使用它绕过它。您可以使用ast.Try运行测试,这会在每次测试开始时打开调试器 ,然后在调试器暂停到断言之前暂停时,发出ast.Raise跳过该调试器

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

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

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

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

答案 1 :(得分:7)

您无需使用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进行其余的测试。

答案 2 :(得分:4)

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

否则,如果您不介意在每个失败的测试的 end 处暂停(就在错误发生之前),而不是在断言失败的那一点暂停,那么您有几种选择。但是请注意,到目前为止,可能已经运行了各种清除代码,例如在测试中打开的关闭文件。可能的选项是:

  1. 您可以使用--pdb option告诉pytest将您带入调试器。

  2. 您可以定义以下修饰符,并用其修饰每个相关的测试功能。 (除了记录消息外,您还可以在此时启动pdb.post_mortem,甚至可以与code.interact中所述的异常发生所在帧的本地语言进行交互式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的自动使用夹具:
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)

答案 3 :(得分:4)

如果您愿意使用Visual Studio Code,一个简单的解决方案就是使用conditional breakpoints

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

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

然后在断言行中添加一个条件断点,该断点仅在断言失败时才会中断:

enter image description here