测试指南#
引言#
在 1.15 版本之前,NumPy 使用 nose 测试框架,现在使用 pytest 框架。为了支持使用旧 NumPy 框架的下游项目,旧框架仍然维护,但 NumPy 的所有测试都应使用 pytest。
我们的目标是 NumPy 中的每个模块和包都应有一套完善的单元测试。这些测试应能够运行给定例程的全部功能,并检验其对错误或意外输入参数的鲁棒性。精心设计的、覆盖率高的测试对于重构的便捷性至关重要。每当发现一个例程的新错误时,您都应该为该特定情况编写一个新测试,并将其添加到测试套件中,以防止该错误在不知不觉中再次出现。
注意
SciPy 使用 numpy.testing 中的测试框架,因此下面展示的所有 NumPy 示例也适用于 SciPy。
测试 NumPy#
NumPy 可以通过多种方式进行测试,选择您觉得舒适的任何方式。
从 Python 内部运行测试#
您可以通过 numpy.test 来测试已安装的 NumPy,例如,要运行 NumPy 的完整测试套件,请使用以下命令:
>>> import numpy
>>> numpy.test(label='slow')
test 方法可以接受两个或多个参数;第一个 label 是一个字符串,指定要测试的内容;第二个 verbose 是一个整数,表示输出的详细程度。有关详细信息,请参阅 numpy.test 的文档字符串。 label 的默认值为 'fast',它将运行标准测试。字符串 'full' 将运行完整的测试集,包括那些被确定为运行缓慢的测试。如果 verbose 为 1 或更低,测试只会显示关于正在运行的测试的信息消息;但如果它大于 1,则测试还将提供关于缺失测试的警告。因此,如果您想运行每个测试并获得关于哪些模块没有测试的消息:
>>> numpy.test(label='full', verbose=2) # or numpy.test('full', 2)
最后,如果您只对测试 NumPy 的子集感兴趣,例如 _core 模块,请使用以下命令:
>>> numpy._core.test()
从命令行运行测试#
如果您想构建 NumPy 以便在 NumPy 本身工作,请使用 spin 工具。要运行 NumPy 的完整测试套件:
$ spin test -m full
测试 NumPy 的子集
$ spin test -t numpy/_core/tests
有关测试的详细信息,请参阅 测试构建。
在多个线程中运行测试#
为了帮助对 NumPy 进行压力测试以检查线程安全性,可以使用 pytest-run-parallel 运行测试套件。要安装 pytest-run-parallel:
$ pip install pytest-run-parallel
要在多个线程中运行测试套件:
$ spin test -p auto # have pytest-run-parallel detect the number of available cores
$ spin test -p 4 # run each test under 4 threads
$ spin test -p auto -- --skip-thread-unsafe=true # run ONLY tests that are thread-safe
当您编写新测试时,值得测试一下以确保它们在 pytest-run-parallel 下不会失败,因为 CI 作业会使用它。有关如何编写线程安全测试的一些技巧可以在此处找到。
注意
理想情况下,您应该使用 free-threaded Python 构建(版本 3.14 或更高版本)来运行 pytest-run-parallel。如果您决定使用非 free-threaded 的 Python 版本,您需要将环境变量 PYTHON_CONTEXT_AWARE_WARNINGS 和 PYTHON_THREAD_INHERIT_CONTEXT 设置为 1。
运行 doctests#
NumPy 文档包含代码示例,即“doctests”。要检查这些示例是否正确,请安装 scipy-doctest 包:
$ pip install scipy-doctest
并运行以下命令之一:
$ spin check-docs -v
$ spin check-docs numpy/linalg
$ spin check-docs -- -k 'det and not slogdet'
请注意,当您使用 spin test 时,doctests 不会被运行。
运行测试的其他方法#
编写自己的测试#
如果您正在编写希望成为 NumPy 一部分的代码,请在开发代码时编写测试。NumPy 包目录中的每个 Python 模块、扩展模块或子包都应有一个对应的 test_<name>.py 文件。Pytest 会检查这些文件中的测试方法(命名为 test*)和测试类(命名为 Test*)。
假设您有一个 NumPy 模块 numpy/xxx/yyy.py,其中包含一个函数 zzz()。要测试此函数,您需要创建一个名为 test_yyy.py 的测试模块。如果您只需要测试 zzz 的一个方面,您可以简单地添加一个测试函数:
def test_zzz():
assert zzz() == 'Hello from zzz'
更常见的是,我们需要将多个测试分组,因此我们创建一个测试类:
# import xxx symbols
from numpy.xxx.yyy import zzz
import pytest
class TestZzz:
def test_simple(self):
assert zzz() == 'Hello from zzz'
def test_invalid_parameter(self):
with pytest.raises(ValueError, match='.*some matching regex.*'):
...
在这些测试方法中,使用 assert 语句或专门的断言函数来测试某个假设是否有效。如果断言失败,测试就会失败。常用的断言函数包括:
numpy.testing.assert_equal,用于测试结果数组与参考数组之间的逐元素精确相等性;numpy.testing.assert_allclose,用于测试结果数组与参考数组之间的逐元素近似相等性(即具有指定的相对和绝对容差);以及numpy.testing.assert_array_less,用于测试结果数组与参考数组之间的逐元素(严格)排序。
默认情况下,这些断言函数仅比较数组中的数值。请考虑使用 strict=True 选项来检查数组的 dtype 和形状。
当需要自定义断言时,请使用 Python 的 assert 语句。请注意,pytest 在内部重写 assert 语句,以便在失败时提供有用的输出,因此应优先于旧版 numpy.testing.assert_。虽然当使用 -O 以优化模式运行 Python 时,普通的 assert 语句会被忽略,但在使用 pytest 运行测试时,这不是问题。
类似地,应优先使用 pytest 函数 pytest.raises 和 pytest.warns,而不是更广泛使用的旧版函数 numpy.testing.assert_raises 和 numpy.testing.assert_warns。这些版本还接受一个 match 参数,该参数应始终用于精确匹配预期的警告或错误。
请注意,test_ 函数或方法不应带有文档字符串,因为这会使得从使用 verbose=2(或类似的详细程度设置)运行的测试套件输出中难以识别测试。使用普通注释(#)来描述测试的意图,并帮助不熟悉的代码阅读者理解代码。
此外,由于 NumPy 的很多代码是遗留代码,最初编写时没有单元测试,因此仍有几个模块尚未包含测试。请随意选择其中一个模块并为其开发测试。
在测试中使用 C 代码#
NumPy 暴露了丰富的 C-API。这些通过 C 扩展模块进行测试,这些模块的编写方式就像它们不知道 NumPy 的内部细节一样,只使用官方的 C-API 接口。例如,此类模块包括测试 `_rational_tests` 中的用户定义 rational dtype,以及二进制发行版中 `_umath_tests` 中的 ufunc 机制测试。从 1.21 版本开始,您还可以编写 C 代码片段并在测试中进行编译,这些代码将作为 C 扩展模块在本地进行编译并加载到 Python 中。
- numpy.testing.extbuild.build_and_import_extension(modname, functions, *, prologue='', build_dir=None, include_dirs=None, more_init='')#
从函数片段列表 functions 构建并导入 C 扩展模块 modname。
- 参数:
- functions函数片段列表
每个片段都是函数名、调用约定、代码片段的序列。
- prologue字符串
放在前面的代码,通常是额外的
#include或#define宏。- build_dirpathlib.Path
构建模块的目录,通常是临时目录。
- include_dirs列表
编译时查找头文件的额外目录。
- more_init字符串
出现在模块 PyMODINIT_FUNC 中的代码。
- 返回:
- out: module
模块已加载并可供使用。
示例
>>> functions = [("test_bytes", "METH_O", """ if ( !PyBytesCheck(args)) { Py_RETURN_FALSE; } Py_RETURN_TRUE; """)] >>> mod = build_and_import_extension("testme", functions) >>> assert not mod.test_bytes('abc') >>> assert mod.test_bytes(b'abc')
标记测试#
像上面那样的未标记测试将在默认的 numpy.test() 运行中执行。如果您想将测试标记为慢速,从而保留给完整的 numpy.test(label='full') 运行,可以使用 pytest.mark.slow 进行标记。
import pytest
@pytest.mark.slow
def test_big(self):
print('Big, slow test')
方法也类似。
class test_zzz:
@pytest.mark.slow
def test_simple(self):
assert_(zzz() == 'Hello from zzz')
设置和拆卸方法#
NumPy 最初使用 xunit 设置和拆卸,这是 pytest 的一个功能。我们现在鼓励使用设置和拆卸方法,这些方法由需要它们的测试显式调用。
class TestMe:
def setup(self):
print('doing setup')
return 1
def teardown(self):
print('doing teardown')
def test_xyz(self):
x = self.setup()
assert x == 1
self.teardown()
这种方法是线程安全的,确保测试可以在 pytest-run-parallel 下运行。使用 pytest 的设置装置(如 xunit 设置方法)通常不是线程安全的,并可能导致线程安全测试失败。
pytest 支持更通用的、具有各种范围的装置,可以通过特殊参数自动使用。例如,特殊参数名 tmp_path 在测试中用于创建临时目录。但是,装置应谨慎使用。
参数化测试#
pytest 的一个非常好的特性是使用 pytest.mark.parametrize 装饰器可以轻松地在各种参数值上进行测试。例如,假设您希望测试 linalg.solve 对于三个数组大小和两种数据类型的组合:
@pytest.mark.parametrize('dimensionality', [3, 10, 25])
@pytest.mark.parametrize('dtype', [np.float32, np.float64])
def test_solve(dimensionality, dtype):
np.random.seed(842523)
A = np.random.random(size=(dimensionality, dimensionality)).astype(dtype)
b = np.random.random(size=dimensionality).astype(dtype)
x = np.linalg.solve(A, b)
eps = np.finfo(dtype).eps
assert_allclose(A @ x, b, rtol=eps*1e2, atol=0)
assert x.dtype == np.dtype(dtype)
Doctests#
Doctests 是记录函数行为并同时允许测试该行为的便捷方法。交互式 Python 会话的输出可以包含在函数的文档字符串中,测试框架可以运行示例并比较实际输出和预期输出。
可以通过将 doctests 参数添加到 test() 调用来运行 doctests;例如,要运行 numpy.lib 的所有测试(包括 doctests):
>>> import numpy as np
>>> np.lib.test(doctests=True)
doctests 的运行方式就好像它们在一个新的 Python 实例中,该实例已经执行了 import numpy as np。属于 NumPy 子包的测试将已导入该子包。例如,对于 `numpy/linalg/tests/` 中的测试,命名空间将这样创建:from numpy import linalg 已执行。
tests/#
与其将代码和测试放在同一个目录中,不如我们将给定子包的所有测试放在一个 tests/ 子目录中。对于我们的示例,如果 `tests/` 目录尚不存在,您需要在 `numpy/xxx/` 中创建一个。因此,test_yyy.py 的路径是 numpy/xxx/tests/test_yyy.py。
编写好 numpy/xxx/tests/test_yyy.py 后,可以通过进入 tests/ 目录并键入以下命令来运行测试:
python test_yyy.py
或者,如果您将 numpy/xxx/tests/ 添加到 Python 路径中,您可以在解释器中像这样交互式地运行测试:
>>> import test_yyy
>>> test_yyy.test()
__init__.py 和 setup.py#
然而,通常不希望将 tests/ 目录添加到 Python 路径中。相反,最好直接从模块 xxx 调用测试。为此,只需在您的包的 __init__.py 文件末尾添加以下几行:
...
def test(level=1, verbosity=1):
from numpy.testing import Tester
return Tester().test(level, verbosity)
您还需要在 setup.py 的配置部分中添加 tests 目录:
...
def configuration(parent_package='', top_path=None):
...
config.add_subpackage('tests')
return config
...
现在您可以执行以下操作来测试您的模块:
>>> import numpy
>>> numpy.xxx.test()
此外,在调用整个 NumPy 测试套件时,您的测试将被找到并运行。
>>> import numpy
>>> numpy.test()
# your tests are included and run automatically!
技巧与窍门#
已知失败和跳过测试#
有时您可能想跳过一个测试或将其标记为已知失败,例如,当测试套件在编写其要测试的代码之前编写时,或者当测试仅在特定架构上失败时。
要跳过一个测试,只需使用 skipif:
import pytest
@pytest.mark.skipif(SkipMyTest, reason="Skipping this test because...")
def test_something(foo):
...
如果 SkipMyTest 的计算结果非零,则测试将被标记为跳过,并且在详细测试输出中的消息是传递给 skipif 的第二个参数。类似地,可以通过使用 xfail 将测试标记为已知失败:
import pytest
@pytest.mark.xfail(MyTestFails, reason="This test is known to fail because...")
def test_something_else(foo):
...
当然,可以通过使用不带参数的 skip 或 xfail 来无条件地跳过测试或将其标记为已知失败。
在测试运行结束时会显示跳过测试和已知失败测试的总数。跳过的测试在测试结果中标记为 'S'(对于 verbose > 1 则标记为 'SKIPPED'),已知失败的测试标记为 'x'(对于 verbose > 1 则标记为 'XFAIL')。
随机数据测试#
对随机数据进行测试是很好的,但由于测试失败是为了暴露新错误或回归,一个大多数时间通过但偶尔会失败而没有任何代码更改的测试是没有帮助的。通过在生成数据之前设置随机数种子来使随机数据确定化。使用 rng = numpy.random.RandomState(some_number) 在 numpy.random.RandomState 的本地实例上设置种子。
或者,您可以使用 Hypothesis 来生成任意数据。Hypothesis 会为您管理 Python 和 NumPy 的随机种子,并提供一种简洁而强大的方式来描述数据(包括 hypothesis.extra.numpy,例如用于一组可相互广播的形状)。
与随机生成相比的优势包括用于回放和共享失败而无需固定种子的工具、报告每次失败的最小示例,以及比朴素随机更好的技术来触发错误。
编写线程安全的测试#
编写线程安全的测试可能需要一些反复试验。通常,您应该遵循前面所述的准则,尤其是在 设置方法 和 随机数据种子 方面。显式设置和使用本地 RNG 是线程安全的做法。以下是一些针对您可能遇到的其他常见问题的提示。
使用 pytest.mark.parametrize 有时可能会导致线程安全问题。要解决此问题,您可以使用 copy()。
@pytest.mark.parametrize('dimensionality', [3, 10, 25])
@pytest.mark.parametrize('dtype', [np.float32, np.float64])
def test_solve(dimensionality, dtype):
dimen = dimensionality.copy()
d = dtype.copy()
# use these copied variables instead
...
如果您正在测试某些本质上不是线程安全的,您可以用 pytest.mark.thread_unsafe 标记您的测试,这样它将在单个线程下运行,而不会导致测试失败。
@pytest.mark.thread_unsafe(reason="reason this test is thread-unsafe")
def test_thread_unsafe():
...
以下是一些应标记为线程不安全的示例:
使用
sys.stdout和sys.stderr。全局数据的变异,如文档字符串、模块、垃圾收集器等。
需要大量内存的测试,因为它们可能导致崩溃。
此外,一些 pytest 装置是线程不安全的,例如 monkeypatch 和 capsys。但是,如果您决定使用它们,pytest-run-parallel 会自动将它们标记为线程不安全。一些装置已被修补为线程安全的,例如 tmp_path。
numpy.test 文档#
- numpy.test(label='fast', verbose=1, extra_argv=None, doctests=False, coverage=False, durations=-1, tests=None)#
Pytest 测试运行器。
测试函数通常像这样添加到包的 __init__.py 文件中:
from numpy._pytesttester import PytestTester test = PytestTester(__name__).test del PytestTester
调用此测试函数将查找并运行与该模块及其所有子模块关联的所有测试。
- 参数:
- module_name模块名
要测试的模块名称。
备注
与之前的基于
nose的实现不同,此类不公开暴露,因为它执行了一些numpy特定的警告抑制。- 属性:
- module_namestr
要测试的包的完整路径。