目录
使用 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.ini
、setup.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=d
。 pytest
警告过滤器“error”将确保如果 pytest
发现任何警告,它将失败。您可以列出应隐藏或仅显示而不成为错误的警告,使用语法 "<action>:Regex for warning message:Warning:package"
,其中 <action>
倾向于为 default
(第一次显示)或 ignore
(从不显示)。除非您在前面加上 .*
,否则正则表达式将在错误的开头匹配。
运行 pytest
您可以使用 pytest
或 python -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.warns
或 pytest.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-one
、test_on_ints-two
和 test_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。