Skip to content

Latest commit

 

History

History
904 lines (654 loc) · 36.6 KB

File metadata and controls

904 lines (654 loc) · 36.6 KB

二、标记和参数化

在学习了编写和运行测试的基础知识之后,我们将深入研究 pytest 的两个重要特性:标记和参数化。

首先,我们将了解标记,它允许我们根据应用的标记有选择地运行测试,并将通用数据附加到测试函数中,这些函数可由夹具和插件使用。在同一主题中,我们将看一看内置标记及其提供的功能。

其次,我们将学习测试参数化,它允许我们轻松地将相同的测试函数应用于一组输入值。这大大避免了重复测试代码,并使添加新的测试用例变得容易,这些测试用例可能会随着软件的发展而出现。

总而言之,以下是我们将在本章中介绍的内容:

  • 标记基础
  • 内置标记
  • 参数化

标记基础

Pytest 允许您使用元数据标记函数和类。此元数据可用于选择性地运行测试,也可用于装置和插件,以执行不同的任务。让我们来看看如何创建和应用标记到测试函数,然后再跳进内置的 PyTestMax。

创建标记

标记是用@pytest.mark装饰器创建的。它作为工厂工作,因此对它的任何访问都将自动创建新标记并将其应用于函数。举个例子,这更容易理解:

@pytest.mark.slow
def test_long_computation():
    ...

通过使用@pytest.mark.slow装饰器,您将在test_long_computation上应用一个名为slow的标记。

标记还可以接收参数:

@pytest.mark.timeout(10, method="thread")
def test_topology_sort():
    ...

上一个示例中使用的@pytest.mark.timeout取自 pytest 超时插件;有关更多详细信息,请访问https://pypi.org/project/pytest-timeout/ 。因此,我们定义test_topology_sort不应超过 10 秒,在这种情况下,应使用thread方法终止。为标记指定参数是一个非常强大的功能,它为插件和装置提供了很大的灵活性。我们将在下一章中探讨这些功能和pytest-timeout插件。

通过多次应用@pytest.mark装饰符,可以在测试中添加多个标记,例如:

@pytest.mark.slow
@pytest.mark.timeout(10, method="thread")
def test_topology_sort():
    ...

如果反复应用同一标记,可以通过将其指定给变量一次并根据需要在测试中应用它来避免重复:

timeout10 = pytest.mark.timeout(10, method="thread")

@timeout10
def test_topology_sort():
    ...

@timeout10
def test_remove_duplicate_points():
    ...

如果此标记在多个测试中使用,则可以将其移动到测试实用程序模块,并根据需要导入:

from mylib.testing import timeout10

@timeout10
def test_topology_sort():
    ...

@timeout10
def test_remove_duplicate_points():
    ...

基于分数运行测试

您可以使用标记作为带有-m标志的选择因子来运行测试。例如,要运行带有slow标记的所有测试:

λ pytest -m slow

-m标志也接受表达式,因此您可以进行更高级的选择。要运行带有slow标记的所有测试,但不运行带有serial标记的测试,您可以使用::

λ pytest -m "slow and not serial"

表达式仅限于and、not和or运算符。

自定义标记可用于优化 CI 系统上的测试运行。通常情况下,环境问题、缺少依赖项,甚至一些错误提交的代码都可能导致整个测试套件失败。通过使用标记,您可以选择一些快速和/或足够广泛的测试来检测代码中的大部分问题,然后在所有其他测试之前先运行这些测试。如果这些测试中的任何一个失败了,我们将中止工作,并通过运行所有注定要失败的测试来避免潜在的浪费大量时间。

我们首先对这些测试应用自定义标记。任何名称都可以,但常用的名称是smoke,如烟雾探测器中所述,用于在所有东西起火之前检测问题。

然后,您首先运行冒烟测试,只有在通过测试后,您才能运行完整的测试套件:

λ pytest -m "smoke"
...
λ pytest -m "not smoke"

如果任何冒烟测试失败,您不必等待整个套件完成以获得此反馈

您可以通过创建从最简单到最慢的测试层,创建排序的测试层次结构来添加此技术。例如:

  • smoke
  • unittest
  • integration
  • <all the rest>

然后执行如下操作:

λ pytest -m "smoke"
...
λ pytest -m "unittest"
...
λ pytest -m "integration"
...
λ pytest -m "not smoke and not unittest and not integration"

确保包括第四步;否则,没有标记的测试将永远不会运行。

使用标记区分不同 pytest 运行中的测试也可以用于其他场景。例如,当使用pytest-xdist插件并行运行测试时,我们有一个并行会话,并行执行大多数测试套件,但可能会决定在单独的 pytest 会话中串行运行一些测试,因为它们在一起执行时是敏感的或有问题的。

给班级打分

您可以将@pytest.mark装饰器应用于类。这将对该类中的所有测试方法应用相同的标记,避免在所有测试方法上复制和粘贴标记代码:

@pytest.mark.timeout(10)
class TestCore:

    def test_simple_simulation(self):
        ...

    def test_compute_tracers(self):
        ...

前面的代码与下面的代码基本相同:

class TestCore:

 @pytest.mark.timeout(10)
    def test_simple_simulation(self):
        ...

    @pytest.mark.timeout(10)
    def test_compute_tracers(self):
        ...

然而,有一个区别:将@pytest.mark装饰器应用于类意味着其所有子类都将继承该标记。子类化测试类并不常见,但它有时是避免重复测试代码或确保实现符合特定接口的有用技术。我们将在本章后面以及第 4 章、固定装置中看到更多此类示例。

与测试函数一样,装饰器可以应用多次:

@pytest.mark.slow
@pytest.mark.timeout(10)
class TestCore:

    def test_simple_simulation(self):
        ...

    def test_compute_tracers(self):
        ...

对模块应用标记

我们还可以对模块中的所有测试函数和测试类应用标记。只需声明一个名为pytestmark的全局变量:

import pytest

pytestmark = pytest.mark.timeout(10)

class TestCore:

    def test_simple_simulation(self):
        ...

def test_compute_tracers():
    ...

以下内容与此等效:

import pytest

@pytest.mark.timeout(10)
class TestCore:

    def test_simple_simulation(self):
        ...

@pytest.mark.timeout(10)
def test_compute_tracers():
    ...

您也可以使用tuple或list标记来应用多个标记:

import pytest

pytestmark = [pytest.mark.slow, pytest.mark.timeout(10)]

自定义标记和 pytest.ini

只需使用pytest.mark装饰器即可即时申报新标记,这非常方便。很快就可以享受使用标记的好处。

这种便利是有代价的:用户可以在标记名中输入错误,例如@pytest.mark.solw,而不是@pytest.mark.slow。根据正在测试的项目,这个输入错误可能只是一个麻烦或更严重的问题。

那么,让我们回到上一个示例,其中测试套件基于标记的测试在 CI 上分层执行:

  • smoke
  • unittest
  • integration
  • <all the rest>
λ pytest -m "smoke"
...
λ pytest -m "unittest"
...
λ pytest -m "integration"
...
λ pytest -m "not smoke and not unittest and not integration"

开发人员在对其中一个测试进行标记时可能会输入错误:

@pytest.mark.smoek
def test_simulation_setup():
    ...

这意味着测试将在最后一步执行,而不是在其他smoke测试的第一步执行。同样,根据测试套件的不同,这可能会从一个小麻烦变为一个可怕的问题。

具有固定标记集的成熟测试套件可能会在pytest.ini文件中声明它们:

[pytest]
markers =
    slow
    serial
    smoke: quick tests that cover a good portion of the code
    unittest: unit tests for basic functionality
    integration: cover to cover functionality testing    

markers选项接受<name>: description形式的标记列表,描述部分是可选的(上例中的slow和serial没有描述)。

使用--markers标志可以显示完整的标记列表:

λ pytest --markers
@pytest.mark.slow:

@pytest.mark.serial:

@pytest.mark.smoke: quick tests that cover a good portion of the code

@pytest.mark.unittest: unit tests for basic functionality

@pytest.mark.integration: cover to cover functionality testing

...

--strict标志使使用pytest.ini文件中未声明的标记成为错误。使用前面的输入错误示例,我们现在获得一个错误,而不是 pytest 在运行--strict时以静默方式创建标记:

λ pytest --strict tests\test_wrong_mark.py
...
collected 0 items / 1 errors

============================== ERRORS ===============================
_____________ ERROR collecting tests/test_wrong_mark.py _____________
tests\test_wrong_mark.py:4: in <module>
 @pytest.mark.smoek
..\..\.env36\lib\site-packages\_pytest\mark\structures.py:311: in __getattr__
 self._check(name)
..\..\.env36\lib\site-packages\_pytest\mark\structures.py:327: in _check
 raise AttributeError("%r not a registered marker" % (name,))
E AttributeError: 'smoek' not a registered marker
!!!!!!!!!!!!!! Interrupted: 1 errors during collection !!!!!!!!!!!!!!
====================== 1 error in 0.09 seconds ======================

要确保所有标记都注册在pytest.ini中的测试套件也应使用addopts:

[pytest]
addopts = --strict
markers =
    slow
    serial
    smoke: quick tests that cover a good portion of the code
    unittest: unit tests for basic functionality
    integration: cover to cover functionality testing

内置标记

在学习了标记的基本知识以及如何使用它们之后,现在让我们看看一些内置的 pytest 标记。这不是所有内置标记的详尽列表,而是更常用的标记。另外,请记住,许多插件也引入了其他标记。

@pytest.mark.skipif

您可能有一些测试,除非满足某些条件,否则不应执行这些测试。例如,某些测试可能依赖于某些并非始终安装的库,或可能不在线或仅在某些平台上执行的本地数据库

Pytest 提供一个内置标记skipif,可以根据具体情况跳过测试。如果条件为 true,则不会执行跳过的测试,并且不会计入测试套件失败。

例如,您可以使用skipif标记在 Windows 上执行时始终跳过测试:

import sys
import pytest

@pytest.mark.skipif(
 sys.platform.startswith("win"),
 reason="fork not available on Windows",
)
def test_spawn_server_using_fork():
    ...

@pytest.mark.skipif的第一个参数是条件:在这个例子中,我们告诉 pytest 跳过 Windows 中的这个测试。reason=关键字参数是必需的,用于显示在使用-ra标志时跳过测试的原因:

 tests\test_skipif.py s                                        [100%]
====================== short test summary info ======================
SKIP [1] tests\test_skipif.py:6: fork not available on Windows
===================== 1 skipped in 0.02 seconds =====================

总是写描述性的信息是一种很好的风格,包括适用的票号。

或者,我们可以编写如下相同的条件:

import os
import pytest

@pytest.mark.skipif(
 not hasattr(os, 'fork'), reason="os.fork not available"
)
def test_spawn_server_using_fork2():
    ...

后一个版本检查实际功能是否可用,而不是基于平台进行假设(Windows 目前没有os.fork功能,但可能将来 Windows 可能支持该功能)。在基于库的版本测试库的功能时,而不是检查某些函数是否存在时,同样的情况也很常见。我建议这样写:有些函数存在。我建议在可能的情况下,最好检查函数是否确实存在,而不是检查库的特定版本。

Checking capabilities and features is usually a better approach, instead of checking platforms and library version numbers. The following is the full @pytest.mark.skipif signature:

@pytest.mark.skipif(condition, *, reason=None)

pytest.skip

@pytest.mark.skipif装饰师非常方便,但标记必须在import/collection时间评估条件,以确定是否应跳过测试。我们希望尽量减少测试收集时间,因为毕竟,如果使用了-k或--lf标志,我们甚至可能不会执行所有测试。

有时,在导入期间检查是否应该跳过测试几乎是不可能的(如果没有一些可怕的攻击)。例如,只有在初始化基础图形库之后,才能根据图形驱动程序的功能决定跳过测试,并且在导入时初始化图形子系统肯定不是您想要做的事情。

对于这些情况,pytest 允许您通过使用pytest.skip函数在测试体中强制跳过测试:

def test_shaders():
    initialize_graphics()
    if not supports_shaders():
 pytest.skip("shades not supported in this driver") # rest of the test code ... 

pytest.skip通过引发内部异常来工作,因此它遵循正常的 Python 异常语义,并且不需要做任何其他事情来正确跳过测试。

pytest.importorskip

库通常具有依赖于所安装的特定库的测试。例如,pytest 自己的测试套件对numpy阵列进行了一些测试,如果没有安装numpy,应该跳过这些测试。

处理此问题的一种方法是手动尝试导入库,如果库不存在,则跳过测试:

def test_tracers_as_arrays_manual():
    try:
        import numpy
    except ImportError:
        pytest.skip("requires numpy")
    ...

这会很快变老,因此 pytest 提供了方便的pytest.importorskip功能:

def test_tracers_as_arrays():
    numpy = pytest.importorskip("numpy")
    ...

pytest.importorskip将导入模块并返回模块对象,如果无法导入模块,则完全跳过测试

如果您的测试需要库的最低版本,pytest.importorskip还支持minversion参数:

def test_tracers_as_arrays_114():
    numpy = pytest.importorskip("numpy", minversion="1.14")
    ...

@pytest.mark.xfail

您可以使用@pytest.mark.xfail装饰符指示测试为*expected to fail*。与往常一样,我们将标记装饰器应用于测试函数或方法:

@pytest.mark.xfail
def test_simulation_34():
    ...

这个标记支持一些参数,我们将在本节后面看到这些参数;但现在有一个特别值得讨论:strict参数。此参数定义标记的两种不同行为:

  • 使用strict=False(默认值),测试将被单独计算为XPASS(如果通过)或XFAIL(如果失败),并且不会使测试套件失败
  • 使用strict=True时,如果测试失败,将标记为XFAIL,但如果测试意外通过,将使测试套件失败,就像正常失败测试一样

但是,为什么您希望编写一个预期会失败的测试,在哪些情况下这是有用的?这在一开始可能看起来很奇怪,但在一些情况下,这很方便。

第一种情况是,测试总是失败,如果测试突然开始通过,你希望别人(大声地)告诉你。这可能发生在以下情况:

  • 您发现代码中的错误是由第三方库中的问题引起的。在这种情况下,您可以编写一个失败的测试来演示问题,并用@pytest.mark.xfail(strict=True)标记它。如果测试失败,测试将在测试会话摘要中标记为XFAIL,但如果测试通过,则测试套件将失败。当您升级导致问题的库时,此测试可能开始通过,这将提醒您该问题已修复,需要注意。
  • 您已经考虑了一个新特性,并设计了一个或多个测试用例,甚至在您开始实现它之前就已经使用了它。您可以提交应用了@pytest.mark.xfail(strict=True)标记的测试,并在编写新功能时从测试中删除该标记。这在协作环境中非常有用,在协作环境中,一个人提供关于如何设想新特性/API 的测试,另一个人根据测试用例实现它
  • 您发现了应用程序中的一个 bug,并编写了一个测试用例来演示这个问题。您可能现在没有时间处理它,或者其他人更适合处理代码的这一部分。在这种情况下,将测试标记为@pytest.mark.xfail(strict=True)将是一个很好的方法。

以上所有情况都有一个共同的特点:你的测试失败了,你想知道它是否突然开始通过。在这种情况下,测试通过的事实警告您需要注意的一个事实:带有 bug 修复的库的新版本已经发布,功能的一部分正在按预期工作,或者已知 bug 已经修复

xfail标记有用的另一种情况是,有时测试失败,也称为片状****测试。脆弱测试是指即使底层代码没有更改,有时也会失败的测试。测试失败的原因有很多,似乎是随机的;以下是一些:

  • 多线程代码中的计时问题
  • 间歇性网络连接问题
  • 不能正确处理异步事件的测试
  • 依赖于非确定性行为

这只是列出几个可能的原因。这种不确定性通常发生在范围更广的测试中,例如集成或 UI。事实上,您几乎总是必须在大型测试套件中处理不稳定的测试。

脆弱的测试是一个严重的问题,因为测试套件被认为是代码按预期工作的指示器,并且它可以在实际问题发生时检测到它们。不可靠的测试破坏了这一形象,因为开发人员经常会看到不可靠的测试失败,而这与最近的代码更改无关。当这种情况变得司空见惯时,人们开始再次运行测试套件,希望这一次脆弱的测试能够通过(通常是这样),但这会侵蚀对整个测试套件的信任,并给开发团队带来挫折。您应该将片状测试视为一种威胁,应该加以控制和处理。

以下是一些关于如何在开发团队中处理脆弱测试的建议:

  1. 首先,您需要能够正确识别不可靠的测试。如果一个测试失败,显然与最近的更改无关,请再次运行测试。如果之前失败的测试现在passes,则表示该测试不可靠。
  2. 创建一个问题来处理您的票证系统中特定的不稳定测试。使用命名约定或其他方式将该问题标记为与薄弱测试相关的问题(例如 GitHub 或 JIRA 标签)。
  3. 应用@pytest.mark.xfail(reason="flaky test #123", strict=False)标记,确保包含发行票编号或标识符。如果您愿意,可以在描述中添加更多信息。
  4. 确保定期将有关不稳定测试的问题分配给自己或其他团队成员(例如,在 sprint 计划期间)。这样做的目的是以舒适的速度处理不稳定的测试,最终减少或完全消除这些测试。

这些实践解决了两个主要问题:它们允许您通过让开发团队避开不可靠的测试来避免侵蚀对测试套件的信任,并且它们在适当的时候制定了处理不可靠测试的策略。

在涵盖了损坏标记的情况下,让我们来看看完整的签名:

@pytest.mark.xfail(condition=None, *, reason=None, raises=None, run=True, strict=False)
  • condition:如果给出第一个参数,则为True/False条件,类似于@pytest.mark.skipif使用的条件:如果为False,则忽略xfail标记。根据外部条件(如平台、Python 版本、库版本等)将测试标记为xfail非常有用。

    @pytest.mark.xfail(
     sys.platform.startswith("win"), 
        reason="flaky on Windows #42", strict=False
    )
    def test_login_dialog():
        ...
  • reason:当使用-ra标志时,将在短测试摘要中显示的字符串。强烈建议始终使用此参数解释测试标记为xfail和/或包含票号的原因。

    @pytest.mark.xfail(
        sys.platform.startswith("win"), 
        reason="flaky on Windows #42", strict=False
    )
    def test_login_dialog():
        ...
  • raises:给定一个异常类型,它声明我们期望测试引发该异常的实例。如果测试引发另一种类型的异常(即使是AssertionError),测试将正常进行fail。它对于缺少功能或测试已知的 bug 特别有用。

    @pytest.mark.xfail(raises=NotImplementedError,
                       reason='will be implemented in #987')
    def test_credential_check():
        check_credentials('Hawkwood') # not implemented yet
  • run:如果False,测试将不会执行,并将失败为 XFAIL。这对于运行可能使测试套件进程崩溃的代码的测试特别有用(例如,C/C++扩展由于已知问题导致分段错误)。

    @pytest.mark.xfail(
        run=False, reason="undefined particles cause a crash #625"
    )
    def test_undefined_particle_collision_crash():
        collide(Particle(), Particle())
  • strict:如果True,通过测试将使测试套件失败。如果为False,则无论结果如何,测试都不会使测试套件失败(默认为False。在本节开头详细讨论了这一点。

配置变量xfail_strict控制xfail标记strict参数的默认值:

[pytest]
xfail_strict = True

将其设置为True意味着所有没有明确strict参数的 xfail 标记测试都被视为实际故障预期,而不是不可靠的测试。任何明确通过strict参数的xfail标记都会覆盖配置值。

pytest.xfail

最后,您可以通过调用pytest.xfail函数在测试中强制触发 XFAIL 结果:

def test_particle_splitting():
    initialize_physics()
    import numpy
    if numpy.__version__ < "1.13":
        pytest.xfail("split computation fails with numpy < 1.13")
    ...

与pytest.skip类似,当您只能确定是否需要在运行时将测试标记为xfail时,这非常有用。

参数化

常见的测试活动是将多个值传递给同一个测试函数并断言结果。

假设我们有一个允许用户定义自定义数学公式的应用程序,这些公式将在运行时进行解析和计算。公式以字符串形式给出,可以使用数学函数,如sin、cos、log等。在 Python 中实现这一点的一个非常简单的方法是使用内置的eval(https://docs.python.org/3/library/functions.html#eval ),但由于它可以执行任意代码,我们选择使用自定义标记器和求值器来代替

让我们不要深入研究实现细节,而是关注一个测试:

def test_formula_parsing():
    tokenizer = FormulaTokenizer()
    formula = Formula.from_string("C0 * x + 10", tokenizer)
    assert formula.eval(x=1.0, C0=2.0) == pytest.approx(12.0)

在这里,我们创建一个Tokenizer类,我们的实现使用该类将公式字符串分解为内部标记,以便稍后处理。然后,我们将公式字符串和标记器传递给Formula.from_string,以获得公式对象。手上拿着 formula 对象,我们将输入值传递给formula.eval并检查返回值是否符合我们的期望

但我们还想测试其他数学运算,以确保我们涵盖了Formula类的所有功能。

一种方法是通过使用多个断言检查其他公式和输入值来扩展测试:

def test_formula_parsing():
    tokenizer = FormulaTokenizer()
    formula = Formula.from_string("C0 * x + 10", tokenizer)
    assert formula.eval(x=1.0, C0=2.0) == pytest.approx(12.0)

    formula = Formula.from_string("sin(x) + 2 * cos(x)", tokenizer)
 assert formula.eval(x=0.7) == pytest.approx(2.1739021)

    formula = Formula.from_string("log(x) + 3", tokenizer)
    assert formula.eval(x=2.71828182846) == pytest.approx(4.0)

这是可行的,但是如果其中一个断言失败,那么测试函数中的以下断言将不会执行。如果出现多个故障,我们将不得不多次运行测试以查看所有故障,并最终修复所有问题。

要查看每个测试运行的多个失败,我们可能会决定为每个断言显式地编写一个单独的测试:

def test_formula_linear():
    tokenizer = FormulaTokenizer()
    formula = Formula.from_string("C0 * x + 10", tokenizer)
    assert formula.eval(x=1.0, C0=2.0) == pytest.approx(12.0)

def test_formula_sin_cos():
    tokenizer = FormulaTokenizer()
    formula = Formula.from_string("sin(x) + 2 * cos(x)", tokenizer)
    assert formula.eval(x=0.7) == pytest.approx(2.1739021)

def test_formula_log():
    tokenizer = FormulaTokenizer()
    formula = Formula.from_string("log(x) + 3", tokenizer)
    assert formula.eval(x=2.71828182846) == pytest.approx(4.0)

但是现在我们到处重复代码,这将使维护更加困难。假设将来FormulaTokenizer被更新为显式接收可在公式中使用的函数列表。这意味着我们必须在几个地方更新FormulaTokenzier的创建

为了避免重复,我们可能会决定写以下内容:

def test_formula_parsing2():
    values = [
 ("C0 * x + 10", dict(x=1.0, C0=2.0), 12.0),
 ("sin(x) + 2 * cos(x)", dict(x=0.7), 2.1739021),
 ("log(x) + 3", dict(x=2.71828182846), 4.0),
 ]
    tokenizer = FormulaTokenizer()
    for formula, inputs, result in values:
        formula = Formula.from_string(formula, tokenizer)
        assert formula.eval(**inputs) == pytest.approx(result)

这解决了复制代码的问题,但现在我们回到了最初的问题,一次只看到一个失败。

输入@pytest.mark.parametrize

为了解决上述所有问题,pytest 提供了广受欢迎的@pytest.mark.parametrize标记。有了这个标记,您就可以为测试提供一个输入值列表,pytest 会自动为每个输入值生成多个测试函数。

下面显示了这一点:

@pytest.mark.parametrize(
 "formula, inputs, result",
 [
 ("C0 * x + 10", dict(x=1.0, C0=2.0), 12.0),
 ("sin(x) + 2 * cos(x)", dict(x=0.7), 2.1739021),
 ("log(x) + 3", dict(x=2.71828182846), 4.0),
 ],
)
def test_formula_parsing(formula, inputs, result):
    tokenizer = FormulaTokenizer()
    formula = Formula.from_string(formula, tokenizer)
    assert formula.eval(**inputs) == pytest.approx(result)

@pytest.mark.parametrize标记会自动生成多个测试函数,并使用标记的参数对其进行参数化。该调用接收两个参数:

  • argnames:将传递给测试函数的参数名称的逗号分隔字符串。
  • argvalues:元组序列,每个元组生成一个新的测试调用。元组中的每一项都对应一个参数名,因此第一个元组("C0 * x + 10", dict(x=1.0, C0=2.0), 12.0)将使用参数生成对测试函数的调用:
    • formula="C0 * x + 10"
    • inputs=dict(x=1.0, C0=2.0)
    • expected=12.0

使用此标记,pytest 将运行test_formula_parsing三次,每次传递argvalues参数给出的一组参数。它还将为每个测试自动生成不同的节点 ID,从而便于区分它们:

======================== test session starts ========================
...
collected 8 items / 5 deselected

test_formula.py::test_formula[C0 * x + 10-inputs0-12.0]
test_formula.py::test_formula[sin(x) + 2 * cos(x)-inputs1-2.1739021]
test_formula.py::test_formula[log(x) + 3-inputs2-4.0] 
============== 3 passed, 5 deselected in 0.05 seconds ===============

同样重要的是要注意,函数体与本节开头的开始测试一样紧凑,但是现在我们有多个测试,允许我们在出现多个故障时看到它们。

测试参数化不仅避免了重复测试代码并使维护更容易,它还邀请您和下一个开发人员在代码成熟时添加更多的输入值。它鼓励开发人员覆盖更多的情况,因为人们更希望在参数化测试的argvalues中添加一行,而不是复制并粘贴整个新测试以覆盖另一个输入值。

总之,@pytest.mark.parametrize将使您以很少的开销覆盖更多的输入案例。这绝对是一个非常有用的特性,当您有多个输入值要以相同的方式进行测试时,应该使用它。

对值集应用标记

通常,在参数化测试中,您会发现需要对一组参数应用一个或多个标记,就像对正常测试函数一样。例如,您希望对一组参数应用timeout标记,因为运行时间太长;或者对一组参数应用xfail标记,因为它尚未实现。

在这些情况下,使用pytest.param包装该组值并应用所需的标记:

@pytest.mark.parametrize(
    "formula, inputs, result",
    [
        ...
        ("log(x) + 3", dict(x=2.71828182846), 4.0),
        pytest.param(
 "hypot(x, y)", dict(x=3, y=4), 5.0,
 marks=pytest.mark.xfail(reason="not implemented: #102"),
 ),
    ],
)

pytest.param的签名如下:

pytest.param(*values, **kw)

哪里:

  • *values为参数设置:"hypot(x, y)", dict(x=3, y=4), 5.0。
  • **kw是作为关键字参数的选项:marks=pytest.mark.xfail(reason="not implemented: #102")。它接受单个标记或一系列标记。还有另一个选项ids,将在下一节中显示。

在幕后,传递给@pytest.mark.parametrize的每个参数元组都被转换为pytest.param而不需要额外的选项,因此,例如,在下面的示例中,第一个代码段相当于第二个代码段:

@pytest.mark.parametrize(
    "formula, inputs, result",
    [
        ("C0 * x + 10", dict(x=1.0, C0=2.0), 12.0),
        ("sin(x) + 2 * cos(x)", dict(x=0.7), 2.1739021),
    ]
)
@pytest.mark.parametrize(
    "formula, inputs, result",
    [
        pytest.param("C0 * x + 10", dict(x=1.0, C0=2.0), 12.0),
        pytest.param("sin(x) + 2 * cos(x)", dict(x=0.7), 2.1739021),
    ]
)

自定义测试 ID

考虑这个例子:

@pytest.mark.parametrize(
    "formula, inputs, result",
    [
        ("x + 3", dict(x=1.0), 4.0,),
        ("x - 1", dict(x=6.0), 5.0,),
    ],
)
def test_formula_simple(formula, inputs, result):
    ...

正如我们所看到的,pytest 根据参数化调用中使用的参数自动创建自定义测试 ID。运行pytest -v将生成这些测试 ID:

======================== test session starts ========================
...
tests/test_formula.py::test_formula_simple[x + 3-inputs0-4.0]
tests/test_formula.py::test_formula_simple[x - 1-inputs1-5.0]

如果您不喜欢自动生成的 ID,可以使用pytest.param和id选项进行自定义:

@pytest.mark.parametrize(
    "formula, inputs, result",
    [
        pytest.param("x + 3", dict(x=1.0), 4.0, id='add'),
        pytest.param("x - 1", dict(x=6.0), 5.0, id='sub'),
    ],
)
def test_formula_simple(formula, inputs, result):
    ...

这将产生以下结果:

======================== test session starts ========================
...
tests/test_formula.py::test_formula_simple[add]
tests/test_formula.py::test_formula_simple[sub]

这也很有用,因为当使用-k 标志时,它使选择测试变得非常容易:

λ pytest -k "x + 3-inputs0-4.0"

与:

λ pytest -k "add"

测试多个实现

设计良好的系统通常使用接口提供的抽象,而不是绑定到特定的实现。这使得系统对未来的变化更具弹性,因为要扩展它,您需要实现一个符合预期接口的新扩展,并将其集成到系统中。

经常出现的一个挑战是如何确保现有实现符合特定接口的所有细节。

例如,假设我们的系统需要能够将一些内部类序列化为文本格式以保存并加载到磁盘。以下是我们系统中的一些内部类:

  • Quantity:表示一个值和一个度量单位。例如Quantity(10, "m")表示10 米。Quantity对象有加法、减法和乘法,基本上是您从本机float中期望的所有运算符,但要考虑度量单位。
  • Pipe:表示一些流体可以流过的管道。它有一个length和diameter,两个Quantity实例。

最初,在我们的开发中,我们只需要以JSON格式保存这些对象,因此我们继续实现一个简单的序列化器类,它能够序列化和反序列化我们的类:

class JSONSerializer:

    def serialize_quantity(self, quantity: Quantity) -> str:
        ...

    def deserialize_quantity(self, data: str) -> Quantity:
        ...

    def serialize_pipe(self, pipe: Pipe) -> str:
        ...

    def deserialize_pipe(self, data: str) -> Pipe:
        ...

现在我们应该编写一些测试来确保一切正常:

class Test:

    def test_quantity(self):
        serializer = JSONSerializer()
        quantity = Quantity(10, "m")
        data = serializer.serialize(quantity)
        new_quantity = serializer.deserialize(data)
        assert new_quantity == quantity

    def test_pipe(self):
        serializer = JSONSerializer()
        pipe = Pipe(
            length=Quantity(1000, "m"), diameter=Quantity(35, "cm")
        )
        data = serializer.serialize(pipe)
        new_pipe = serializer.deserialize(data)
        assert new_pipe == pipe

考虑到我们的需求,这是一种非常有效的方法。

一段时间过去了,新的需求到来了:现在我们需要将我们的对象序列化为其他格式,即XML和YAML()http://yaml.org/ 。为了简单起见,我们创建了两个新类,XMLSerializer和YAMLSerializer,它们实现了相同的serialize/deserialize方法。因为它们遵循与JSONSerializer相同的接口,所以我们可以在我们的系统中互换使用这些新类,这非常好。

但是我们如何测试不同的实现呢?

一种简单的方法是在每个测试中循环不同的实现:

class Test:

    def test_quantity(self):
        for serializer in [
 JSONSerializer(), XMLSerializer(), YAMLSerializer()
 ]:
            quantity = Quantity(10, "m")
            data = serializer.serialize(quantity)
            new_quantity = serializer.deserialize(data)
            assert new_quantity == quantity

    def test_pipe(self):
        for serializer in [
 JSONSerializer(), XMLSerializer(), YAMLSerializer()
 ]:
            pipe = Pipe(
                length=Quantity(1000, "m"),
                diameter=Quantity(35, "cm"),
            )
            data = serializer.serialize(pipe)
            new_pipe = serializer.deserialize(data)
            assert new_pipe == pipe

这是可行的,但并不理想,因为我们必须在每个测试中复制和粘贴循环定义,这使得维护变得更加困难。此外,如果其中一个序列化程序失败,则永远不会执行列表中的下一个序列化程序。

另一种可怕的方法是复制和粘贴整个测试函数,每次都替换序列化器类,但我们在这里不介绍这一点。

更好的解决方案是在班级级别使用@pytest.mark.parametrize。注意:

@pytest.mark.parametrize(
 "serializer_class",
 [JSONSerializer, XMLSerializer, YAMLSerializer],
)
class Test:

    def test_quantity(self, serializer_class):
        serializer = serializer_class()
        quantity = Quantity(10, "m")
        data = serializer.serialize(quantity)
        new_quantity = serializer.deserialize(data)
        assert new_quantity == quantity

    def test_pipe(self, serializer_class):
        serializer = serializer_class()
        pipe = Pipe(
            length=Quantity(1000, "m"), diameter=Quantity(35, "cm")
        )
        data = serializer.serialize(pipe)
        new_pipe = serializer.deserialize(data)
        assert new_pipe == pipe

通过一个小改动,我们将现有的测试成倍增加,以覆盖所有新的实现:

test_parametrization.py::Test::test_quantity[JSONSerializer] PASSED
test_parametrization.py::Test::test_quantity[XMLSerializer] PASSED
test_parametrization.py::Test::test_quantity[YAMLSerializer] PASSED
test_parametrization.py::Test::test_pipe[JSONSerializer] PASSED
test_parametrization.py::Test::test_pipe[XMLSerializer] PASSED
test_parametrization.py::Test::test_pipe[YAMLSerializer] PASSED

@pytest.mark.parametrizedecorator 还非常明确地指出,应该将新的实现添加到列表中,并且所有现有的测试都必须通过。添加到类中的新测试也需要通过所有实现。

总之,@pytest.mark.parametrize是一个非常强大的工具,可以确保不同的实现符合接口规范

总结

在本章中,我们学习了如何使用标记来组织代码,并帮助我们以灵活的方式运行测试套件。然后我们研究了如何使用@pytest.mark.skipif有条件地跳过测试,以及如何使用@pytest.mark.xfail标记来处理预期的失败和不稳定的测试。然后,我们讨论了在协作环境中处理片状测试的方法。最后,我们讨论了使用@pytest.mark.parametrize的好处,以避免重复我们的测试代码,并使我们自己和其他人能够轻松地向现有测试添加新的输入案例。

在下一章中,我们将最终讨论 pytest 最受欢迎和最强大的功能之一:fixture。