目录

使用 pytest 进行测试

测试对于编写可靠的软件至关重要。一套良好的测试套件允许您

  • 立即知道新平台或软件版本是否有效,
  • 自信地重构和清理代码,
  • 评估添加和更改的影响。

Python 曾经有三种主要的测试选择;但现在 pytest 几乎被独家使用。测试永远不是安装要求,因此使用 pytest 没有任何坏处。编写优质测试的目标是

  • 简单性:您的测试越简单/越易于编写,您编写的测试就越多。
  • 覆盖率:使用尽可能多的输入可以增加找到导致错误内容的可能性。
  • 性能:测试速度越快,您可以在 CI 中运行测试的场景就越多。
  • 报告:当出现故障时,您应该获得有关故障原因的详细信息。

其他选择怎么样?

替代库 nose 已被弃用,取而代之的是 pytest,它可以运行 nose 风格的测试。标准库也有一个测试套件,但它非常冗长且复杂;并且由于“开发者”运行测试,因此您的测试需求不会影响用户。而且 pytest 也可以运行标准库风格的测试。因此,只需使用 pytest 即可。所有主要软件包也使用它,包括 NumPy。大多数其他选择,如 Hypothesis,都与 pytest 相关,并且只是对其进行扩展。

基本测试结构

您应该在存储库中创建一个名为“tests”的文件夹(可能与您的 src 或模块文件夹并排,但很少将它们放在模块文件夹内)。您的文件将被称为 test_*.py;pytest 的默认发现期望每个文件中都包含“test”一词(是的,您可以将测试放在模块旁边或模块内部,因为有此功能)。这是一个测试示例

test_basic.py:

def test_funct():
    assert 4 == 2**2

这看起来很简单,但它做了很多事情

  • 函数的名称包含 test,因此它将作为测试运行。
  • Python assert 语句由 pytest 重写以准确捕获发生的情况。如果失败,您将获得有关每个值的清晰详细报告。

配置 pytest

pytest 支持在 pytest.inisetup.cfg 或从版本 6 开始的 pyproject.toml PP301 中进行配置。请记住,pytest 是开发人员的要求,而不是用户的要求,因此始终要求 6+(或 7+)并使用 pyproject.toml。这是一个配置示例

[tool.pytest.ini_options]
minversion = "6.0"
addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"]
xfail_strict = true
filterwarnings = ["error"]
log_cli_level = "info"
testpaths = [
  "tests",
]

PP302 如果您的 pytest 版本过旧,则 minversion 将打印更友好的错误消息(尽管具有讽刺意味的是,如果版本过旧,它将无法读取此消息,因此在 pyproject.toml 中设置“6”或更低版本毫无意义)。addopts 设置将在您运行时将您放入其中的任何内容添加到命令行;PP308 -ra 将打印所有结果的摘要“r”eport“a”,这为您提供了一种快速查看哪些测试失败和跳过以及原因的方法。--showlocals 将在回溯中打印局部变量。PP307 --strict-markers 将确保您不会尝试使用未指定的夹具。PP306 并且 --strict-config 如果您在配置中出错,将导致错误。PP305 xfail_strict 将更改 xfail 的默认值,如果测试没有失败,则将其更改为使测试失败 - 您仍然可以在特定 xfail 中本地覆盖它以进行不稳定的故障。PP309 filter_warnings 将导致所有警告都成为错误(您也可以在此处添加允许的警告,请参见下文)。PP304 log_cli_level 将在失败时报告 INFO 及以上级别的日志消息。PP303 最后,testpaths 将限制 pytest 只查找给定的文件夹 - 如果它尝试从其他目录中获取不是测试的内容,则很有用。请参阅文档 以获取更多选项。

pytest 还会检查当前目录和父目录以查找 conftest.py 文件。如果找到它们,它们将从外到内运行。这些文件允许您为每个目录添加夹具和其他 pytest 配置(如测试发现的钩子等)。例如,您可以有一个“mock”文件夹,在该文件夹中,您可以有一个 conftest.py,其中包含一个具有 autouse=True 的模拟夹具,然后该文件夹中的每个测试都将应用此模拟。

一般来说,不要在测试中放置 __init__.py 文件;通常没有理由使测试目录可导入,并且它可能会混淆软件包发现算法。

Python 默认情况下隐藏了重要的警告,主要是因为它试图对用户友好。如果您是开发人员,您不希望它“友好”。您希望在警告导致用户错误之前找到并修复它们!在本地,您应该使用 -Wd 运行,或者在环境中设置 export PYTHONWARNINGS=dpytest 警告过滤器“error”将确保如果 pytest 发现任何警告,它将失败。您可以列出应隐藏或仅显示而不成为错误的警告,使用语法 "<action>:Regex for warning message:Warning:package",其中 <action> 倾向于为 default(第一次显示)或 ignore(从不显示)。除非您在前面加上 .*,否则正则表达式将在错误的开头匹配。

运行 pytest

您可以使用 pytestpython -m pytest 直接运行 pytest。您可以选择提供要运行的目录或文件。您还可以使用 -k <expression> 选择一些测试子集,或使用 filename.py::test_name 选择一个确切的测试。

如果测试失败,您可以使用许多选项来节省调试时间。添加 -l/--showlocals 将在回溯中打印出局部值(并且可以默认添加,请参见上文)。您可以使用 --pdb 运行 pytest,这将在每次失败时都将您放入调试器中。或者,您可以使用 --trace,它将在您选择的每个测试开始时将您放入调试器中(因此可能使用上述选择方法)。pytest 也支持 breakpoint()。您还可以使用 --trace --lf 在上次失败的测试开始时进入调试器。请参阅文档 以获取更多运行技巧。

编写优质测试的指南

花时间学习 pytest 提供的所有强大工具将是值得的!您可以使测试更细化,模拟不可用(或运行缓慢)的内容,参数化等等。

测试应该简单易懂

始终使用 pytest。内置的 unittest 非常冗长;测试编写越简单,您编写的测试就越多!

import unittest


class MyTestCase(unittest.TestCase):
    def test_something(self):
        x = 1
        self.assertEquals(x, 2)

将其与 pytest 进行对比

def test_something():
    x = 1
    assert x == 2

即使 pytest 似乎使用了 Python 的 assert 语句,它仍然可以提供清晰的错误分解,包括 x 的实际值!你不需要设置类(尽管你可以),也不需要记住 50 个左右不同的 self.assert* 函数!pytest 还可以运行 unittest 测试,以及旧的 nose 包的测试。

近似相等通常很难检查,但 pytest 也让它变得很容易。

from pytest import approx


def test_approx():
    0.3333333333333 == approx(1 / 3)

这在 NumPy 数组中也能原生工作!如果可以,始终优先使用 array1 == approx(array2) 而不是 numpy.testing 模块中的函数,它更简单,并且报告更好。

测试也应该测试失败的情况

你应该确保抛出了预期的错误。

import pytest


def test_raises():
    with pytest.raises(ZeroDivisionError):
        1 / 0

你还可以使用 pytest.warnspytest.deprecated_call 检查警告。

测试在扩展时应该保持简单

pytest 使用 fixture 来表示复杂的概念,例如 setup/teardown、临时资源或参数化。

fixture 看起来像函数参数;pytest 通过名称识别它们。

def test_printout(capsys):
    print("hello")

    captured = capsys.readouterr()
    assert "hello" in captured.out

创建新的 fixture 并不困难,可以放在测试文件中或 conftest.py 中。

@pytest.fixture(params=[1, 2, 3], ids=["one", "two", "three"])
def ints(request):
    return request.param

我们可以省略 ids,但对于复杂的输入,这可以让测试拥有漂亮的名称。

现在,你可以使用它了。

def test_on_ints(ints):
    assert ints**2 == ints * ints

现在你将得到三个测试,test_on_ints-onetest_on_ints-twotest_on_ints-three

Fixture 可以作用域,允许简单的 setup/teardown(如果你需要运行 teardown,可以使用 yield)。你甚至可以设置 autouse=True 来始终在一个文件或模块中使用 fixture(通过 conftest.py)。你也可以在嵌套文件夹中拥有 conftest.py

这是一个高级示例,它还使用了 monkeypatch,这是一种将难以拆分为单元的代码转换为单元测试的好方法。假设你想创建一个测试,使你的代码“误以为”它在不同的平台上运行。

import platform
import pytest


@pytest.fixture(params=["Linux", "Darwin", "Windows"], autouse=True)
def platform_system(request, monkeypatch):
    monkeypatch.setattr(platform, "system", lambda _: request.param)

现在,此文件中(或如果在 conftest 中,则为该目录中)的每个测试都将运行三次,并且每次都会识别为不同的 platform.system()!省略 autouse,它将变为选择加入;将 platform_system 添加到参数列表中将选择加入。

测试应该被组织起来

你可以使用 pytest.mark.*标记 测试,以便你可以轻松地打开或关闭一组测试,或者对标记的测试执行其他特殊操作,例如标记为“slow”的测试。只需在运行 pytest 时添加 -m <marker> 即可运行一组标记的测试。这是一个表达式;你也可以使用 not <marker>

可能最有用的内置标记是 skipif

@pytest.mark.skipif("sys.version_info >= (3, 8)")
def test_only_on_38plus():
    x = 3
    assert f"{x = }" == "x = 3"

现在,此测试仅在 Python 3.8 或更高版本上运行,并在早期版本上跳过。你不需要为条件使用字符串,但如果你不使用,请添加 reason=,这样仍然会有一个不错的打印输出解释为什么跳过了测试。

你还可以使用 xfail 用于预期会失败的测试(如果你愿意,你甚至可以严格地测试它们是否失败)。你可以使用 parametrize 创建一个单独的参数化测试,而不是共享它们(使用 fixture)。还有一个 filterwarnings 标记。

许多 pytest 插件也支持新的标记,例如 pytest-parametrize。你还可以使用自定义标记来启用/禁用一组测试,或将数据传递给 fixture。

测试应该测试已安装的版本,而不是本地版本

你的测试应该针对代码的已安装版本运行。针对本地版本进行测试可能在已安装版本不起作用时也能工作(由于缺少文件、更改路径等)。这是使用 /src/package 而不是 /package 的主要原因之一,因为 python -m pytest 将拾取本地目录,而 pytest 不会。此外,可能有一天有人(可能是你)需要从 wheel 或 conda 包中运行你的测试,或者在构建系统中运行,如果你无法针对已安装的版本进行测试,你就无法运行你的测试!(这种情况比你想象的要多)。

模拟昂贵或棘手的调用

如果你必须调用一些昂贵或难以调用的东西,通常最好模拟它。为了隔离你自己的代码的一部分进行“单元”测试,模拟也很有用。结合猴子补丁(在前面的示例中显示),这是一个非常强大的工具!

假设我们要编写一个调用 matplotlib 的函数。我们可以使用 pytest-mpl 来捕获图像并在我们的测试中进行比较,但这是一个集成测试,而不是单元测试;如果确实出了问题,我们就会被困在比较图片上,而且我们不知道我们的 matplotlib 使用方式从测试报告中发生了什么变化。让我们看看如何模拟它。我们将使用 pytest-mock 插件用于 pytest,它只是以更原生的 pytest 方式(作为 fixture 等)调整内置的 unittest.mock

@pytest.fixture
def mock_matplotlib(mocker):
    fig = mocker.Mock(spec=matplotlib.pyplot.Figure)
    ax = mocker.Mock(spec=matplotlib.pyplot.Axes)
    line2d = mocker.Mock(name="step", spec=matplotlib.lines.Line2D)
    ax.plot.return_value = (line2d,)

    # Patch the main library if used directly
    mpl = mocker.patch("matplotlib.pyplot", autospec=True)
    mocker.patch("matplotlib.pyplot.subplots", return_value=(fig, ax))

    return SimpleNamespace(fig=fig, ax=ax, mpl=mpl)

在这里,我们只是模拟了我们在需要测试的绘图函数中触及的部分。我们使用 spec= 使模拟只响应原始对象将响应的相同内容。我们可以设置返回值,使我们的对象表现得像真实对象一样。使用它很简单。

def test_my_plot(mock_matplotlib):
    ax = mock_matplotlib.ax
    my_plot(ax=ax)

    assert len(ax.mock_calls) == 6

    ax.plot.assert_called_once_with(
        approx([1.0, 3.0, 2.0]),
        label=None,
        linewidth=1.5,
    )

如果发生变化,我们立即就能知道发生了什么变化——而且运行速度很快,我们没有生成任何图像!虽然设置起来需要一些工作,但从长远来看,它会带来回报。

pytest-mock 上的文档很有帮助,尽管大部分内容只是重定向到标准库 unittest.mock