第 6 章 Markers

在 pytest 中,标记是告诉 pytest 特定测试有什么特别之处的一种方式。 您可以将它们视为标签(tags)或标签(labels)。 如果某些测试很慢,您可以使用 @pytest.mark.slow 标记它们,并让 pytest 在您赶时间时跳过这些测试。 您可以从测试套件中选择一些测试,并用 @pytest.mark.smoke 标记它们,并将它们作为持续集成系统中测试管道的第一阶段运行。 实际上,出于任何原因您可能需要分离一些测试,您都可以使用标记。

pytest 包含一些内置标记,可以修改测试运行方式的行为。 我们已经在第 64 页的参数化函数中使用了一个 @pytest.mark.parametrize。除了我们可以创建并添加到测试中的自定义标签式标记之外,内置标记还告诉 pytest 做一些特殊的事情 标记的测试。

在本章中,我们将探讨两种类型的标记:改变行为的内置标记,以及我们可以创建来选择要运行的测试的自定义标记。 我们还可以使用标记将信息传递给测试使用的夹具。 我们也会看看这个。

使用内置标记

pytest 的内置标记用于修改测试运行方式的行为。我们在上一章中探索了 @pytest.mark.parametrize()。 以下是自 pytest 6 起 pytest 中包含的内置标记的完整列表:

  • @pytest.mark.filterwarnings(warning):该标记向给定测试添加警告过滤器。

  • @pytest.mark.skip(reason=None):此标记以可选原因跳过测试。

  • @pytest.mark.skipif(condition, …​, *, Reason):如果任何条件为 True,则此标记将跳过测试。

  • @pytest.mark.xfail(condition, …​, *, Reason, run=True, raises=None, strict=xfail_strict):这个标记告诉 pytest 我们预计测试会失败。

  • @pytest.mark.parametrize(argnames,argvalues,indirect,ids,scope):该标记多次调用测试函数,依次传递不同的参数。

  • @pytest.mark.usefixtures(fixturename1,fixturename2,…​):此标记将测试标记为需要所有指定的 fixture。

这些是这些内置函数中最常用的:

  • @pytest.mark.parametrize()

  • @pytest.mark.skip()

  • @pytest.mark.skipif()

  • @pytest.mark.xfail()

我们在上一章中使用了 parameterize()。 让我们通过一些例子来回顾一下其他三个,看看它们是如何工作的。

使用 pytest.mark.skip 跳过测试

skip 标记允许我们跳过测试。 假设我们正在考虑在 Cards 应用程序的未来版本中添加排序功能,因此我们希望 Card 类支持比较。 我们编写一个测试来将 Card 对象与 < 进行比较,如下所示:

ch6/builtins/test_less_than.py
from cards import Card


def test_less_than():
    c1 = Card("a task")
    c2 = Card("b task")
    assert c1 < c2


def test_equality():
    c1 = Card("a task")
    c2 = Card("a task")
    assert c1 == c2

它失败了:

$ cd /path/to/code/ch6/builtins
$ pytest --tb=short test_less_than.py
========================= test session starts ==========================
collected 2 items
test_less_than.py F. [100%]
=============================== FAILURES ===============================
____________________________ test_less_than ____________________________
test_less_than.py:6: in test_less_than
assert c1 < c2
E TypeError: '<' not supported between instances of 'Card' and 'Card'
======================= short test summary info ========================
FAILED test_less_than.py::test_less_than - TypeError: '<' not support...
===================== 1 failed, 1 passed in 0.13s ======================

现在的失败并不是软件的缺陷,而是软件的缺陷。 只是我们还没有完成这个功能。 那么我们用这个测试来做什么呢?

一种选择是跳过它。 让我们这样做:

ch6/builtins/test_skip.py
from cards import Card

import pytest


@pytest.mark.skip(reason="Card doesn't support < comparison yet")
def test_less_than():
    c1 = Card("a task")
    c2 = Card("b task")
    assert c1 < c2

@pytest.mark.skip() 标记告诉 pytest 跳过测试。 reason 是可选的,但重要的是列出原因以帮助以后进行维护。

当我们运行跳过的测试时,它们显示为 s:

$ pytest test_skip.py
========================= test session starts ==========================
collected 2 items
test_skip.py s. [100%]
===================== 1 passed, 1 skipped in 0.03s =====================

或者详细描述为 SKIPPED:

$ pytest -v -ra test_skip.py
========================= test session starts ==========================
collected 2 items
test_skip.py::test_less_than SKIPPED (Card doesn't support <...) [ 50%]
test_skip.py::test_equality PASSED [100%]
======================= short test summary info ========================
SKIPPED [1] test_skip.py:6: Card doesn't support < comparison yet
===================== 1 passed, 1 skipped in 0.03s =====================

底部的额外行列出了我们在标记中给出的原因,之所以存在,是因为我们在命令行中使用了 -ra 标志。 -r 标志告诉 pytest 在会话结束时报告不同测试结果的原因。 您可以为其指定一个字符,该字符代表您想要了解更多信息的结果类型。 默认显示与传入 -rfE: f 相同,表示测试失败; E 表示错误。 您可以使用 pytest --help 查看整个列表。

-ra 中的 a 代表 “除了通过之外的所有”。 因此 -ra 标志是最有用的,因为我们几乎总是想知道某些测试未通过的原因。

我们还可以更具体,仅在满足某些条件时跳过测试。 我们接下来看看。

使用 pytest.mark.skipif 有条件地跳过测试

假设我们知道 Cards 应用程序的 1.x.x 版本不支持排序,但 2.x.x 版本支持排序。 我们可以告诉 pytest 跳过对低于 2.x.x 的所有 Cards 版本的测试,如下所示:

ch6/builtin/test_skipif.py
import pytest
from cards import Card


import cards
from packaging.version import parse


@pytest.mark.skipif(
    parse(cards.__version__).major < 2,
    reason="Card < comparison not supported in 1.x",
)
def test_less_than():
    c1 = Card("a task")
    c2 = Card("b task")
    assert c1 < c2

Skipif 标记允许您传递任意数量的条件,如果其中任何一个条件为真,则跳过测试。 在我们的例子中,我们使用 Packaging.version.parse 来隔离主要版本并将其与数字 2 进行比较。

此示例使用称为打包的第三方包。 如果您想尝试该示例,请先 pip install 打包。 version.parse 只是那里找到的许多方便的实用程序之一。 有关详细信息,请参阅包装文档。

使用 skip 和 skipif 标记,测试实际上并没有运行。 如果我们无论如何都想运行测试,我们可以使用 xfail。

我们可能想要使用 skipif 的另一个原因是,如果我们有需要在不同操作系统上以不同方式编写的测试。 我们可以为每个操作系统编写单独的测试,并跳过不合适的操作系统。

使用 pytest.mark.xfail 预期测试失败

如果我们想要运行所有测试,即使是那些我们知道会失败的测试,我们可以使用 xfail 标记。

这是 xfail 的完整签名:

@pytest.mark.xfail(condition, ..., *, reason, run=True, raises=None, strict=xfail_strict)

该装置的第一组参数与 skipif 相同。 默认情况下,测试无论如何都会运行,但 run 参数可用于通过设置 run=False 来告诉 pytest 不要运行测试。 raises 参数允许您提供想要导致 xfail 的异常类型或异常类型元组。 任何其他异常都会导致测试失败。 strict 告诉 pytest 通过测试是否应该标记为 XPASS (strict=False) 或 FAIL, strict=True。

让我们看一个例子:

ch6/builtins/test_xfail.py
import pytest
import cards
from cards import Card
from packaging.version import parse


@pytest.mark.xfail(
    parse(cards.__version__).major < 2,
    reason="Card < comparison not supported in 1.x",
)
def test_less_than():
    c1 = Card("a task")
    c2 = Card("b task")
    assert c1 < c2


@pytest.mark.xfail(reason="XPASS demo")
def test_xpass():
    c1 = Card("a task")
    c2 = Card("a task")
    assert c1 == c2


@pytest.mark.xfail(reason="strict demo", strict=True)
def test_xfail_strict():
    c1 = Card("a task")
    c2 = Card("a task")
    assert c1 == c2

我们这里有三项测试:一项我们知道会失败,两项我们知道会通过。 这些测试演示了使用 xfail 的失败和通过情况以及使用 strict 的效果。 第一个示例还使用可选条件参数,其工作方式类似于 skipif 的条件。

这是它们运行时的样子:

$ pytest -v -ra test_xfail.py
========================= test session starts ==========================
collected 3 items
test_xfail.py::test_less_than XFAIL (Card < comparison not s...) [ 33%]
test_xfail.py::test_xpass XPASS (XPASS demo) [ 66%]
test_xfail.py::test_xfail_strict FAILED [100%]
=============================== FAILURES ===============================
__________________________ test_xfail_strict ___________________________
[XPASS(strict)] strict demo
======================= short test summary info ========================
XFAIL test_xfail.py::test_less_than
Card < comparison not supported in 1.x
XPASS test_xfail.py::test_xpass XPASS demo
FAILED test_xfail.py::test_xfail_strict
=============== 1 failed, 1 xfailed, 1 xpassed in 0.11s ================

对于标有 xfail 的测试:

  • 测试失败将导致 XFAIL。

  • 通过测试(无严格设置)将导致 XPASSED。

  • 通过 strict=true 的测试将导致 FAILED。

当标记为 xfail 的测试失败时,pytest 确切地知道要告诉你什么:“你是对的,它确实失败了”,这就是它用 XFAIL 表达的意思。 对于实际通过的标有 xfail 的测试,pytest 不太确定要告诉您什么。 它可能会导致 XPASSED,大致意思是 “好消息,您认为会失败的测试刚刚通过了”。 或者它可能会导致失败,或者,“你以为它会失败,但它没有。 你错了。”

所以你必须做出决定。 您通过的 xfail 测试是否会导致 XFAIL? 如果是,则不要管严格。 如果您希望它们失败,请设置严格。 您可以像我们在本示例中所做的那样将 strict 设置为 xfail 标记的选项,也可以使用 pytest.ini(pytest 的主配置文件)中的 xfail_strict=true 设置进行全局设置。

始终使用 xfail_strict 的一个务实原因是我们倾向于仔细查看所有失败的测试。 设置 strict 可以让您研究测试期望与代码行为不匹配的情况。

您可能想要使用 xfail 还有几个其他原因:

  • 您首先以测试驱动的开发风格编写测试,并且处于测试编写区域,编写一堆您知道尚未实现但计划很快实现的测试用例。 您可以使用 xfail 标记新行为,并在实现该行为时逐渐删除 xfail。 这确实是我最喜欢的 xfail 用法。 尝试将 xfail 测试保留在正在实现该功能的功能分支上。 或者

  • 出现故障,测试(或更多)失败,并且需要修复故障的人员或团队无法立即解决该问题。 将测试标记为 xfail, strict=true,并写入包含缺陷/问题报告 ID 的原因,这是保持测试运行的好方法,不要忘记它,并在错误修复时提醒您。

使用use xfail 或skip 也有不好的理由。 这是一个:

假设您只是在集思广益,讨论您在未来版本中可能想要或可能不想要的行为。 您可以将测试标记为 xfail 或跳过,以便在您确实想要实现该功能时保留它们。 不。

在这种情况下,或类似的情况下,请尝试记住 YAGNI(“Ya Aren’t Gonna Need It”),它来自极限编程并指出:“始终在您真正需要它们时实现它们,而不是在您只是预见到需要它们时实现它们 ” 提前查看并为您即将实现的功能编写测试可能会很有趣且有用。 然而,试图对未来看得太远是浪费时间。 不要这样做。 我们的最终目标是让所有测试都通过,而skip和xfail则不通过。

内置标记 skip、skipif 和 xfail 在您需要时非常方便,但很快就会被过度使用。请小心。

现在让我们换个角度,看看我们自己创建的标记,用于标记我们想要作为一个组运行或跳过的测试。

使用自定义标记选择测试

自定义标记是我们自己编写并应用于测试的标记。 将它们想象成标签或标签。 自定义标记可用于选择要运行或跳过的测试。

要查看自定义标记的实际效果,让我们看一下 “开始” 行为的几个测试:

ch6/smoke/test_start_unmarked.py
import pytest
from cards import Card, InvalidCardId


def test_start(cards_db):
    """
    start changes state from "todo" to "in prog"
    """
    i = cards_db.add_card(Card("foo", state="todo"))
    cards_db.start(i)
    c = cards_db.get_card(i)
    assert c.state == "in prog"


def test_start_non_existent(cards_db):
    """
    Shouldn't be able to start a non-existent card.
    """
    any_number = 123  # any number will be invalid, db is empty
    with pytest.raises(InvalidCardId):
        cards_db.start(any_number)

假设我们想用 “smoke” 标记我们的一些测试,特别是快乐路径测试用例。 将测试子集分割成冒烟测试套件是一种常见的做法,以便能够运行一组有代表性的测试,这些测试将告诉我们任何主系统是否出现严重故障。 此外,我们将用 “异常” 标记我们的一些测试——那些检查预期异常的测试。 嗯,对于这个测试文件来说选择非常容易,因为只有两个测试。 让我们用 “smoke” 标记 test_start,用 “exception” 标记 test_start_non_existent。

我们将从 “smoke” 开始,并将 @pytest.mark.smoke 添加到 test_start():

ch6/smoke/test_start.py
import pytest
from cards import Card, InvalidCardId


@pytest.mark.smoke
def test_start(cards_db):
    """
    start changes state from "todo" to "in prog"
    """
    i = cards_db.add_card(Card("foo", state="todo"))
    cards_db.start(i)
    c = cards_db.get_card(i)
    assert c.state == "in prog"

现在我们应该能够使用 -m Smoke 标志来选择此测试:

$ cd /path/to/code/ch6/smoke
$ pytest -v -m smoke test_start.py
========================= test session starts ==========================
collected 2 items / 1 deselected / 1 selected
test_start.py::test_start PASSED [100%]
=========================== warnings summary ===========================
test_start_smoke.py:6
/path/to/code/ch6/tests/test_start.py:6:
PytestUnknownMarkWarning: Unknown pytest.mark.smoke - is this a typo?
You can register custom marks to avoid this warning
...
@pytest.mark.smoke
...
============== 1 passed, 1 deselected, 1 warning in 0.01s ==============

好吧,它确实只运行一个测试,但我们也收到了警告: Unknown pytest.mark.smoke - 这是一个拼写错误吗?

尽管一开始可能很烦人,但这个警告却是一个救星。 它可以帮助你避免犯错误,比如用 smok、somke、soke 或其他什么东西来标记测试,而实际上你真正指的是 Smoke。 pytest 希望我们注册自定义标记,以便它可以帮助我们避免拼写错误。 凉爽的。 没问题。 我们通过向 pytest.ini 添加标记部分来注册自定义标记。 列出的每个标记均采用 <marker_name>: <description> 形式,如下所示:

ch6/reg/pytest.ini
[pytest]
markers =
    smoke: subset of tests

现在 pytest 不会警告我们有关未知标记的信息:

$ cd /path/to/code/ch6/reg
$ pytest -v -m smoke test_start.py
========================= test session starts ==========================
collected 2 items / 1 deselected / 1 selected
test_start.py::test_start PASSED [100%]
=================== 1 passed, 1 deselected in 0.01s ====================

让我们对 test_start_non_existent 的 “异常” 标记做同样的事情。 首先,在 pytest.ini 中注册标记:

ch6/reg/pytest.ini
[pytest]
markers =
   smoke: subset of tests
➤ exception: check for expected exceptions

其次,将标记添加到测试中:

ch6/reg/test_start.py
➤ @pytest.mark.exception
def test_start_non_existent(cards_db):
    """
    Shouldn't be able to start a non-existent card.
    """
    any_number = 123 # any number will be invalid, db is empty
    with pytest.raises(InvalidCardId):
        cards_db.start(any_number)

第三,使用 -m 异常运行它:

$ pytest -v -m exception test_start.py
========================= test session starts ==========================
collected 2 items / 1 deselected / 1 selected
test_start.py::test_start_non_existent PASSED [100%]
=================== 1 passed, 1 deselected in 0.01s ====================

使用标记来选择一项测试(我们已经做了两次)并不是标记的真正亮点。 当我们涉及更多文件时,它开始变得有趣。

标记文件、类和参数

通过 test_start.py 中的测试,我们添加了 @pytest.mark.<marker_name> 装饰器来测试函数。 我们还可以向整个文件或类添加标记来标记多个测试,或者放大到参数化测试并标记单个参数化。 我们甚至可以在一次测试中放置多个标记。 多有趣啊。 我们将在 test_finish.py 中使用所有提到的标记类型。

让我们从文件级标记开始:

ch6/multiple/test_finish.py
import pytest
from cards import Card, InvalidCardId


pytestmark = pytest.mark.finish

如果 pytest 在测试模块中看到 pytestmark 属性,它将将该标记应用于该模块中的所有测试。 如果要对文件应用多个标记,可以使用列表形式:pytestmark = [pytest.mark.marker_one, pytest.mark.marker_two]

一次标记多个测试的另一种方法是将测试放在类中并使用类级别标记:

ch6/multiple/test_finish.py
@pytest.mark.smoke
class TestFinish:
    def test_finish_from_todo(self, cards_db):
        i = cards_db.add_card(Card("foo", state="todo"))
        cards_db.finish(i)
        c = cards_db.get_card(i)
        assert c.state == "done"

    def test_finish_from_in_prog(self, cards_db):
        i = cards_db.add_card(Card("foo", state="in prog"))
        cards_db.finish(i)
        c = cards_db.get_card(i)
        assert c.state == "done"

    def test_finish_from_done(self, cards_db):
        i = cards_db.add_card(Card("foo", state="done"))
        cards_db.finish(i)
        c = cards_db.get_card(i)
        assert c.state == "done"

测试类 TestFinish 标记有 @pytest.mark.smoke。 像这样标记测试类可以有效地使用相同的标记来标记类中的每个测试方法。 您还可以标记单个测试,但我们在本例中没有这样做。

标记文件或类一次会向多个测试添加标记。 我们还可以放大并仅标记参数化测试的特定测试用例、参数化:

ch6/multiple/test_finish.py
@pytest.mark.parametrize(
    "start_state",
    [
        "todo",
        pytest.param("in prog", marks=pytest.mark.smoke),
        "done",
    ],
)
def test_finish_func(cards_db, start_state):
    i = cards_db.add_card(Card("foo", state=start_state))
    cards_db.finish(i)
    c = cards_db.get_card(i)
    assert c.state == "done"

函数 test_finish_func() 没有直接标记,但它的参数化之一被标记:pytest.param("in prog",marks=pytest.mark.smoke)。 您可以使用列表形式使用多个标记:marks=[pytest.mark.one, pytest.mark.two]。 如果您确实想标记参数化测试的所有测试用例,只需像添加常规函数一样在参数化装饰器上方或下方添加标记即可。

前面的例子是函数参数化。 您还可以用相同的方式标记夹具参数化:

ch6/multiple/test_finish.py
@pytest.fixture(
    params=[
        "todo",
        pytest.param("in prog", marks=pytest.mark.smoke),
        "done",
    ]
)
def start_state_fixture(request):
    return request.param


def test_finish_fix(cards_db, start_state_fixture):
    i = cards_db.add_card(Card("foo", state=start_state_fixture))
    cards_db.finish(i)
    c = cards_db.get_card(i)
    assert c.state == "done"

如果您想向一个函数添加多个标记,没问题,只需将它们堆叠起来即可。 例如,test_finish_non_existent() 标记有 @pytest.mark.smoke 和 @pytest.mark.exception:

ch6/multiple/test_finish.py
@pytest.mark.smoke
@pytest.mark.exception
def test_finish_non_existent(cards_db):
    i = 123  # any number will do, db is empty
    with pytest.raises(InvalidCardId):
        cards_db.finish(i)

我们以多种不同的方式向 test_finish.py 添加了几个标记。

让我们使用标记来选择要运行的测试,但我们不会针对一个测试文件,而是让 pytest 从两个测试文件中进行选择。

使用 -m exception 应该只挑选出两个异常测试:

$ cd /path/to/code/ch6/multiple
$ pytest -v -m exception
========================= test session starts ==========================
collected 12 items / 10 deselected / 2 selected
test_finish.py::test_finish_non_existent PASSED [ 50%]
test_start.py::test_start_non_existent PASSED [100%]
=================== 2 passed, 10 deselected in 0.06s ===================

现在我们用 smoke 标记了一堆东西。 让我们看看 -m Smoke 得到了什么:

$ pytest -v -m smoke
========================= test session starts ==========================
collected 12 items / 5 deselected / 7 selected
test_finish.py::TestFinish::test_finish_from_todo PASSED [ 14%]
test_finish.py::TestFinish::test_finish_from_in_prog PASSED [ 28%]
test_finish.py::TestFinish::test_finish_from_done PASSED [ 42%]
test_finish.py::test_finish_func[in prog] PASSED [ 57%]
test_finish.py::test_finish_fix[in prog] PASSED [ 71%]
test_finish.py::test_finish_non_existent PASSED [ 85%]
test_start.py::test_start PASSED [100%]
=================== 7 passed, 5 deselected in 0.03s ====================

好的。-m Smoke 标志拾取所有 TestFinish 类测试方法、参数化测试中的每个参数化以及 test_start.py 中的一个测试。

最后,-m finish 应该获取 test_finish.py 中的所有内容:

$ pytest -v -m finish
========================= test session starts ==========================
collected 12 items / 2 deselected / 10 selected
test_finish.py::TestFinish::test_finish_from_todo PASSED [ 10%]
test_finish.py::TestFinish::test_finish_from_in_prog PASSED [ 20%]
test_finish.py::TestFinish::test_finish_from_done PASSED [ 30%]
test_finish.py::test_finish_func[todo] PASSED [ 40%]
test_finish.py::test_finish_func[in prog] PASSED [ 50%]
test_finish.py::test_finish_func[done] PASSED [ 60%]
test_finish.py::test_finish_fix[todo] PASSED [ 70%]
test_finish.py::test_finish_fix[in prog] PASSED [ 80%]
test_finish.py::test_finish_fix[done] PASSED [ 90%]
test_finish.py::test_finish_non_existent PASSED [100%]
=================== 10 passed, 2 deselected in 0.03s ===================

在这种特殊情况下,使用仅用于该文件的标记来标记单个文件可能看起来有点愚蠢。 然而,一旦我们进行了一些 CLI 级别的测试,我们可能希望能够按 CLI 与 API 或按功能对测试进行分组。 标记使我们能够对测试进行分组,无论测试位于目录/文件结构中的哪个位置。

使用“and”、“or”、“not”和括号作为标记

我们可以组合标记并使用一些逻辑来帮助选择测试,就像我们在第 69 页的使用关键字选择测试用例中使用 -k 关键字所做的那样。

我们可以使用 -m 运行处理异常的 “完成” 测试 “完成和例外”:

$ pytest -v -m "finish and exception"
========================= test session starts ==========================
collected 12 items / 11 deselected / 1 selected
test_finish.py::test_finish_non_existent PASSED [100%]
=================== 1 passed, 11 deselected in 0.01s ===================

我们可以找到所有未包含在冒烟测试中的完成测试:

$ pytest -v -m "finish and not smoke"
========================= test session starts ==========================
collected 12 items / 8 deselected / 4 selected
test_finish.py::test_finish_func[todo] PASSED [ 25%]
test_finish.py::test_finish_func[done] PASSED [ 50%]
test_finish.py::test_finish_fix[todo] PASSED [ 75%]
test_finish.py::test_finish_fix[done] PASSED [100%]
=================== 4 passed, 8 deselected in 0.02s ====================

我们还可以使用 “and”、“or”、“not” 和括号来非常具体地描述标记:

$ pytest -v -m "(exception or smoke) and (not finish)"
========================= test session starts ==========================
collected 12 items / 10 deselected / 2 selected
test_start.py::test_start PASSED [ 50%]
test_start.py::test_start_non_existent PASSED [100%]
=================== 2 passed, 10 deselected in 0.01s ===================

我们还可以结合标记和关键词进行选择。 让我们运行不属于 TestFinish 类的 smoke 测试:

$ pytest -v -m smoke -k "not TestFinish"
========================= test session starts ==========================
collected 12 items / 8 deselected / 4 selected
test_finish.py::test_finish_func[in prog] PASSED [ 25%]
test_finish.py::test_finish_fix[in prog] PASSED [ 50%]
test_finish.py::test_finish_non_existent PASSED [ 75%]
test_start.py::test_start PASSED [100%]
=================== 4 passed, 8 deselected in 0.02s ====================

使用标记和关键字时要记住的一件事是,标记名称必须在 -m <marker_name> 标志中完整,而关键字更像是 -k <keyword> 中的子字符串。 例如,-k “not TestFini” 工作正常,但 -m smok 则不行。

那么如果你拼错了一个标记会发生什么呢? 这让我们进入了 --strict-markers 的主题。

严格标记

假设我们想要将 “smoke” 标记添加到 test_start_non_existent,就像我们为 test_finish_non_existent 所做的那样。 然而,我们碰巧将 “smoke” 错拼为 “smok”,如下所示:

ch6/bad/test_start.py
@pytest.mark.smok
@pytest.mark.exception
def test_start_non_existent(cards_db):
    """
    Shouldn't be able to start a non-existent card.
    """
    any_number = 123  # any number will be invalid, db is empty
    with pytest.raises(InvalidCardId):
        cards_db.start(any_number)

如果我们尝试运行这个 “smoke” 测试,我们会收到一个熟悉的警告:

$ cd /path/to/code/ch6/bad
$ pytest -m smoke
========================= test session starts ==========================
collected 12 items / 5 deselected / 7 selected
test_finish.py ...... [ 85%]
test_start.py . [100%]
=========================== warnings summary ===========================
test_start.py:17
/path/to/code/ch6/bad/test_start.py:17:
PytestUnknownMarkWarning:
Unknown pytest.mark.smok - is this a typo? ...
@pytest.mark.smok
...
============== 7 passed, 5 deselected, 1 warning in 0.06s ==============

但是,如果我们希望该警告成为错误,我们可以使用 --strict-markers 标志:

$ pytest --strict-markers -m smoke
========================= test session starts ==========================
collected 10 items / 1 error / 4 deselected / 5 selected
================================ ERRORS ================================
____________________ ERROR collecting test_start.py ____________________
'smok' not found in `markers` configuration option
======================= short test summary info ========================
ERROR test_start.py
!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!
==================== 4 deselected, 1 error in 0.15s ====================

那么,有什么区别呢? 首先,错误是在收集时发出的,而不是在运行时发出的。 如果您的测试套件长度超过一两秒,您会很高兴快速获得反馈。 其次,错误有时比警告更容易捕获,尤其是在持续集成系统中。 我建议始终使用 --strict-markers。 您可以将 --strict-markers 添加到 pytest.ini 的 addopts 部分,而不是一直键入它:

ch6/strict/pytest.ini
[pytest]
markers =
    smoke: subset of tests
    exception: check for expected exceptions
    finish: all of the "cards finish" related tests
addopts =
    --strict-markers

打开严格标记是我一直想要但几乎从未想过的事情,所以我尝试始终将其放在我的 pytest.ini 文件中。

将标记与夹具组合

标记可以与固定装置结合使用。 它们还可以与插件和挂钩函数结合使用(但这是第 15 章 “构建插件” 第 205 页的主题)。 在这里,我们将结合标记和固定装置来帮助测试卡片应用程序。

内置标记采用参数,而我们迄今为止使用的自定义标记则不采用参数。 让我们创建一个名为 num_cards 的新标记,我们可以将其传递给 cards_db 固定装置。

cards_db 固定装置当前会为每个想要使用它的测试清除数据库:

ch6/combined/test_three_cards.py
@pytest.fixture(scope="function")
def cards_db(session_cards_db):
    db = session_cards_db
    db.delete_all()
    return db

例如,如果我们想在测试开始时在数据库中拥有三张卡(cards),我们可以编写一个不同但相似的夹具:

ch6/combined/test_three_cards.py
@pytest.fixture(scope="function")
def cards_db_three_cards(session_cards_db):
    db = session_cards_db
    # start with empty
    db.delete_all()
    # add three cards
    db.add_card(Card("Learn something new"))
    db.add_card(Card("Build useful tools"))
    db.add_card(Card("Teach others"))
    return db

然后,我们可以使用原始固定装置进行需要空数据库的测试,并使用新固定装置进行需要数据库包含三张卡(cards)的测试:

ch6/combined/test_three_cards.py
def test_zero_card(cards_db):
    assert cards_db.count() == 0


def test_three_card(cards_db_three_cards):
    cards_db = cards_db_three_cards
    assert cards_db.count() == 3

嗯,太好了。 现在,当我们开始时,我们可以选择在数据库中包含零张或三张卡片。 如果我们想要一张卡、四张卡、或者二十张卡怎么办? 我们为每个人编写一个固定装置吗? 不。 如果我们能够直接告诉设备我们想要从测试中得到多少张牌,那就太好了。 标记使这成为可能。

我们希望能够这样写:

ch6/combined/test_num_cards.py
@pytest.mark.num_cards(3)
def test_three_cards(cards_db):
    assert cards_db.count() == 3

为此,我们需要首先声明一个标记,修改 cards_db 固定装置以检测是否使用了该标记,然后读取作为标记参数提供的值以计算出要预填充的卡片数量。 此外,对卡片信息进行硬编码效果不会很好,因此我们将寻求一个名为 Faker3 的 Python 包的帮助,该包方便地包含一个创建虚假数据的 pytest 夹具。

首先,我们需要安装 Faker:

$ pip install Faker

然后我们需要声明我们的标记:

ch6/combined/pytest.ini
[pytest]
markers =
    smoke: subset of tests
    exception: check for expected exceptions
    finish: all of the "cards finish" related tests
    num_cards: number of cards to prefill for cards_db fixture
addopts =
    --strict-markers

现在我们需要修改 cards_db 夹具:

ch6/combined/conftest.py
@pytest.fixture(scope="function")
def cards_db(session_cards_db, request, faker):
    db = session_cards_db
    db.delete_all()

    # support for `@pytest.mark.num_cards(<some number>)`

    # random seed
    faker.seed_instance(101)
    m = request.node.get_closest_marker("num_cards")
    if m and len(m.args) > 0:
        num_cards = m.args[0]
        for _ in range(num_cards):
            db.add_card(
                Card(summary=faker.sentence(), owner=faker.first_name())
            )
    return db

这里有很多变化,所以让我们来看看它们。

我们将request和faker添加到cards_db参数列表中。 我们对 m = request.node.get_closest_marker('num_cards') 行使用 request。 术语 request.node 是 pytest 测试的表示。 如果测试标记有 num_cards,则 get_closest_marker('num_cards') 返回 Marker 对象,否则返回 None。 函数 get_closest_marker() 的名称乍一看似乎很奇怪。 只有一个标记。 是什么使它成为最接近的一个? 好吧,请记住,我们可以在测试、类甚至文件上放置标记。 get_closest_marker('num_cards') 返回最接近测试的标记,这通常是我们想要的。

如果测试用 num_cards 标记,并且提供了参数,则表达式 m 和 len(m.args) > 0 将为 true。 额外的 len 检查是为了如果有人不小心只使用 pytest.mark.num_cards 而不指定卡片数量,那么我们会跳过这一部分。 我们还可以引发异常或断言某些内容,这会非常提醒用户他们做错了什么。 但是,我们假设它与他们所说的 num_cards(0) 相同。

一旦我们知道要创建多少张牌,我们就让 Faker 为我们创建一些数据。 Faker提供了faker固定装置。 对 faker.seed_instance(101) 的调用为 Faker 提供了随机性,以便我们每次都能获得相同的数据。 我们不会使用 Faker 来获取随机数据,而是使用它来避免我们自己编造数据。 对于摘要字段,faker.sentence() 方法将起作用。 faker.first_name() 为所有者工作。 Faker 还有大量其他功能可供您使用。 我鼓励您在 Faker 文档中搜索适合您自己项目的其他功能。

就是这样……真的。 现在,我们所有不使用标记的旧测试仍然会以相同的方式工作,并且需要数据库中的一些初始卡的新测试也可以使用相同的固定装置:

ch6/combined/test_num_cards.py
import pytest


def test_no_marker(cards_db):
    assert cards_db.count() == 0


@pytest.mark.num_cards
def test_marker_with_no_param(cards_db):
    assert cards_db.count() == 0


@pytest.mark.num_cards(3)
def test_three_cards(cards_db):
    assert cards_db.count() == 3
    # just for fun, let's look at the cards Faker made for us
    print()
    for c in cards_db.list_cards():
        print(c)


@pytest.mark.num_cards(10)
def test_ten_cards(cards_db):
    assert cards_db.count() == 10

还有一件事:我经常好奇假数据是什么样的,所以我在 test_third_cards() 中添加了一些打印语句。

让我们运行这些以确保其正常工作,并查看此假数据的示例:

$ cd /path/to/code/ch6/combined
$ pytest -v -s test_num_cards.py
========================= test session starts ==========================
collected 4 items
test_num_cards.py::test_no_marker PASSED
test_num_cards.py::test_marker_with_no_param PASSED
test_num_cards.py::test_three_cards
Card(summary='Suggest training much grow any me own true.',
owner='Todd', state='todo', id=1)
Card(summary='Forget just effort claim knowledge.',
owner='Amanda', state='todo', id=2)
Card(summary='Line for PM identify decade.',
owner='Russell', state='todo', id=3)
PASSED
test_num_cards.py::test_ten_cards PASSED
========================== 4 passed in 0.06s ===========================

这些句子很奇怪而且毫无意义。 然而,他们使用了测试代码的技巧。 如果我们愿意的话,使用 Faker 和我们的标记/夹具组合可以让我们创建一个包含独特卡牌的大型数据库。

使用标记和固定装置以及第三方包的最后一个示例有点有趣,但也展示了将 pytest 的不同功能(它们本身可能很简单)组合成更大的行为的巨大威力 比各部分之和。 只需很少的努力,我们只需将 @pytest.mark.num_cards(<any number>) 添加到测试中,即可将 cards_db 固定装置从零条目的数据库访问转换为具有我们想要的任意数量条目的数据库。 这非常酷,而且使用起来非常简单。

列表标记

我们在本章中介绍了很多标记。 我们使用内置标记 skip、skipif 和 xfail。 我们创建了自己的标记、smoke、exception、finish 和 num_cards。 还有一些内置标记。 当我们开始使用 pytest 插件时,这些插件可能还包含一些标记。

要列出所有可用的标记,包括描述和参数,请运行 pytest --markers

$ cd /path/to/code/ch6/multiple
$ pytest --markers
@pytest.mark.smoke: subset of tests
@pytest.mark.exception: check for expected exceptions
@pytest.mark.finish: all of the "cards finish" related tests
@pytest.mark.num_cards: number of cards to prefill for cards_db fixture
...
@pytest.mark.skip(reason=None): skip the given test function with
an optional reason. ...
@pytest.mark.skipif(condition, ..., *, reason=...): skip the given test
function if any of the conditions evaluate to True. ...
@pytest.mark.xfail(condition, ..., *, reason=..., run=True,
raises=None, strict=xfail_strict): mark the test function as an expected
failure if any of the conditions evaluate to True. ...
@pytest.mark.parametrize(argnames, argvalues): call a test function multiple
times passing in different arguments in turn. ...
...

这是一个超级方便的功能,可以让我们快速查找标记,也是使用我们自己的标记包含有用描述的一个很好的理由。

回顾

在本章中,我们研究了自定义标记、内置标记以及如何使用标记将数据传递到装置。 我们还介绍了 pytest.ini 的一些新选项和更改。

这是一个 pytest.ini 文件示例:

[pytest]
markers =
    <marker_name>: <marker_description>
    <marker_name>: <marker_description>

addopts =
    --strict-markers
    -ra

xfail_strict = true
  • 自定义标记是用标记部分声明的。

  • --strict-markers 标志告诉 pytest 在发现我们使用未声明的标记时引发错误。 默认是警告。

  • -ra 标志告诉 pytest 列出任何未通过的测试的原因。 这包括失败、错误、跳过、xfail 和 xpass。

  • 设置 xfail_strict = true 会将所有标有 xfail 的通过测试变成失败测试,因为我们对系统行为的理解是错误的。 如果您希望 xfail 测试通过并导致 XPASS,请忽略此项。

  • 自定义标记可用于选择测试子集以使用 -m <标记名称> 运行或使用 -m “not <标记名称>” 不运行。

  • 使用语法 @pytest.mark.<marker_name> 将标记放置在测试上。

  • 类上的标记也使用 @pytest.mark.<marker_name> 语法,并将导致每个类测试方法被标记。

  • 文件可以有标记,使用 pytestmark = pytest.mark.<marker_name> 或pytestmark = [pytest.mark.<marker_one>, pytest.mark.<marker_two>]。

  • 对于参数化测试,可以使用 pytest.param(<实际参数>,marks=pytest.mark.<marker_name>) 来标记单个参数化。 与文件版本一样,参数化版本可以接受标记列表。

  • -m 标志可以使用逻辑运算符 and、or、not 和括号。

  • pytest --markers 列出所有可用的标记。

  • 内置标记提供额外的行为功能,我们讨论了 skip、skipif 和 xfail。

  • 测试可以有多个标记,并且一个标记可以用于多个测试。

  • 从固定装置中,您可以使用 request.node.get_closest_marker(<marker_name>) 访问标记。

  • 标记可以具有可通过 .args.kwargs 属性访问的参数。

  • Faker 是一个方便的 Python 包,它提供了一个名为 faker 的 pytest 固定装置来生成假数据。

练习

使用标记进行测试选择是一个强大的 pytest 功能,可以帮助运行测试子集。 完成这些练习将帮助您熟悉它们。

目录 /path/to/code/ch6/exercises 有几个文件:

exercises/ch6
├── pytest.ini
└── test_markers.py

test_markers.py 包含七个测试用例:

$ cd ch6/exercises
$ pytest -v
========================= test session starts ==========================
collected 7 items
test_markers.py::test_one PASSED [ 14%]
test_markers.py::test_two PASSED [ 28%]
test_markers.py::test_three PASSED [ 42%]
test_markers.py::TestClass::test_four PASSED [ 57%]
test_markers.py::TestClass::test_five PASSED [ 71%]
test_markers.py::test_param[6] PASSED [ 85%]
test_markers.py::test_param[7] PASSED [100%]
  1. 修改 pytest.ini,注册三个标记,odd、testclass、all。

  2. 将所有奇数测试用例标记为 odd。

  3. 使用文件级标记添加全部标记。

  4. 使用 testclass 标记来标记测试类。

  5. 使用 all 标记运行所有测试。

  6. 运行奇怪的测试。

  7. 运行未用测试类标记的奇怪测试。

  8. 运行参数化的奇怪测试。 (提示:同时使用标记和关键字标志。)

下一步

到目前为止,在本书中您已经了解了 pytest 的所有主要功能。 现在你已经准备好将这些力量释放到一个毫无戒心的项目上…​…​哇哈哈哈!

实际上,在本书的下一部分中,我们将为 Cards 项目构建一个完整的测试套件,并学习许多与测试实际项目相关的技能。 我们将研究测试策略并构建测试套件,使用代码覆盖率来查看是否遗漏了任何内容,使用模拟来测试用户界面,学习如何调试测试失败,使用 tox 设置开发工作流程,学习 pytest 如何与持续集成系统良好配合,并了解如果您正在测试可安装的 Python 包以外的其他内容,如何告诉 pytest 您的代码在哪里。

哇! 好多啊。 但这会很有趣。