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


18

我正在使用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'")

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

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

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

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


3
您能给我们一个您想做的代码示例吗?
mrblewog

1
不要使用,assert而是编写自己的检查函数以执行所需的操作。
molbdnilo

你为什么不插入断言块和错误消息
Prathik Kini

1
听起来您真正想要的是pytest用于测试用例。它支持使用断言跳过测试以及更多使编写测试套件更容易的功能。
blubberdiblub19年

1
编写一个简单的工具用机械替换assert cond, "msg"代码中的每一个工具不是很简单_assertCustom("assert cond, 'msg'")吗?单线可能sed会做到这一点。
NPE

Answers:


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

使用此开关,当测试失败时,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然后退出)。

这是一个示例实现,可以打印出我们拒绝调试并立即返回的结果,除非将test提升为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 >>>上方的横幅,因此使用此挂钩来阻止调试器运行意味着您根本看不到此输出。您可以拥有自己的钩子,然后在测试失败“很有趣”时委托给原始钩子,因此可以独立于所使用的调试器来过滤测试失败!您可以通过按名称访问内部实现来访问它;内部挂钩插件的名称为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.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_interacthooksp。一个更完整的实现将必须遍历plugin-manager中的所有插件,以自动覆盖,使用config.pluginmanager.list_name_pluginhasattr()测试每个插件的任意插件。

使失败彻底消失

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

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

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

outcome = yield

在挂钩包装器实现中,您可以通过访问挂钩结果,包括测试异常outcome.excinfo。如果测试中引发异常,则此属性设置为(类型,实例,回溯)的元组。或者,您可以调用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%]

由您决定最适合您的用例的是您。对于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命令跳过断言本身。您必须进行权衡;在调试器下运行所有​​测试(这很慢,因为解释器必须为每个语句调用跟踪函数),或者仅将其应用于失败的测试,并付出从头开始重新运行这些测试的代价。

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


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进行其余的测试。


当测试用例失败或测试步骤失败时,此操作将停止吗?
Nitesh

请检查链接的文档:doc.pytest.org/en/latest/…–
gnvk

好主意。但是,如果使用--pdb,则测试用例将在每次失败时暂停。我可以在运行时决定要暂停哪个测试用例

5

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

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

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

  2. 您可以定义以下修饰符,并用其修饰每个相关的测试功能。(除了记录一条消息,您还可以在此时启动pdb.post_mortem,甚至是一个交互式代码。请与该异常发生所在的帧的本地对象进行交互,如此答案中所述。)

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)

我喜欢装饰器的答案,但不能动态完成。我想动态控制何时要暂停或不暂停。有什么解决办法吗?
Nitesh

动态以什么方式?如在单个开关中一样在任何地方启用/禁用它?还是通过某种方式来控制每个测试?
乌里·格兰塔

假设我正在运行一些测试用例。在中间,我需要暂停失败。我将启用该开关。稍后,我觉得我需要禁用该开关。
Nitesh

您的答案中的装饰者:2对我不起作用,因为我的测试用例将具有多个断言
Nitesh

关于“开关”,您可以更新的实现,pause_on_assert以从文件读取以决定是否暂停。
Uri Granta

4

如果您愿意使用Visual Studio Code,一种简单的解决方案是使用条件断点

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

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

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

在此处输入图片说明


@Nitesh-我认为此解决方案可以解决您的所有问题,只有在断言失败时才中断,您可以在那里调试代码,然后可以继续进行其余的测试……尽管设置起来比较麻烦最初
尼克·马丁
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.