测试指南#

引言#

在 1.15 版本发布之前,NumPy 使用的是 nose 测试框架,现在改用 pytest 框架。旧框架仍被保留以支持使用旧版 NumPy 框架的下游项目,但 NumPy 的所有测试都应使用 pytest。

我们的目标是 NumPy 中的每个模块和包都应具备一套详尽的单元测试。这些测试应检验给定例程的全部功能,以及其对错误或意外输入参数的稳健性。设计良好且覆盖率高的测试将极大地简化重构工作。每当在例程中发现新 bug 时,您都应针对该特定情况编写一个新的测试,并将其添加到测试套件中,以防止该 bug 在未来悄然重现。

注意

SciPy 使用来自 numpy.testing 的测试框架,因此下文中展示的所有 NumPy 示例同样适用于 SciPy。

测试 NumPy#

NumPy 可以通过多种方式进行测试,请选择您觉得顺手的方式。

在 Python 内部运行测试#

您可以通过 numpy.test 来测试已安装的 NumPy,例如,要运行 NumPy 的完整测试套件,请使用以下命令:

>>> import numpy
>>> numpy.test(label='slow')

测试方法可以接收两个或多个参数;第一个 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

有关测试的详细信息,请参阅 测试构建 (Testing builds)。

在多线程中运行测试#

为了帮助对 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 作业会使用它。关于如何编写线程安全测试的一些提示可以在这里找到。

注意

理想情况下,您应该使用 3.14 或更高版本的自由线程版 Python (free-threaded build of Python) 来运行 pytest-run-parallel。如果您决定使用非自由线程版本的 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。

其他运行测试的方法#

使用您最喜欢的 IDE(如 vscode 或 pycharm)运行测试。

编写您自己的测试#

如果您正在编写想要纳入 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 语句或专门的断言函数来测试某个假设是否有效。如果断言失败,则测试失败。常见的断言函数包括:

默认情况下,这些断言函数仅比较数组中的数值。请考虑使用 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。这些 API 使用 C 扩展模块进行测试,这些模块编写时表现得仿佛对 NumPy 的内部细节一无所知,仅使用官方的 C-API 接口。这类模块的示例包括 _rational_tests 中针对用户自定义 rational 数据类型的测试,或者作为二进制分发一部分的 _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 中的代码。

返回:
输出:模块

模块已被加载并准备好使用。

示例

>>> 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')

设置 (Setup) 和拆卸 (Teardown) 方法#

NumPy 最初使用 xunit 风格的 setup 和 teardown,这是 pytest 的一个特性。现在,我们鼓励使用由需要它们的测试明确调用的 setup 和 teardown 方法。

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 设置夹具(fixtures)(如 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 会话的输出可以包含在函数的文档字符串中,测试框架可以运行该示例并将实际输出与预期输出进行比较。

可以通过在 test() 调用中添加 doctests 参数来运行这些测试;例如,要运行 numpy.lib 的所有测试(包括 doctests):

>>> import numpy as np
>>> np.lib.test(doctests=True)

Doctests 的运行方式就好像它们处于一个已执行过 import numpy as np 的全新 Python 实例中。属于 NumPy 子包的测试将已经导入了该子包。例如,对于 numpy/linalg/tests/ 中的测试,命名空间的创建方式使得 from numpy import linalg 已经执行。

tests/#

我们不是将代码和测试放在同一个目录中,而是将特定子包的所有测试放在一个 tests/ 子目录中。对于我们的示例,如果它尚不存在,您需要在 numpy/xxx/ 中创建一个 tests/ 目录。因此,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!

提示与技巧 (Tips & Tricks)#

已知故障与跳过测试#

有时您可能想跳过某个测试或将其标记为已知故障,例如当测试套件是在待测代码编写之前编写的,或者测试仅在特定架构上失败时。

要跳过测试,只需使用 skipif:

import pytest

@pytest.mark.skipif(SkipMyTest, reason="Skipping this test because...")
def test_something(foo):
    ...

如果 SkipMyTest 的计算结果为非零,则测试被标记为跳过,verbose 测试输出中的消息即为赋予 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')。

基于随机数据的测试#

基于随机数据的测试很好,但由于测试失败旨在暴露新 bug 或回归,一个大多数时候通过但偶尔在代码未改动的情况下失败的测试是没有帮助的。通过在生成随机数据之前设置随机数种子,使其具有确定性。使用 rng = numpy.random.RandomState(some_number) 在 numpy.random.RandomState 的本地实例上设置种子。

或者,您可以使用 Hypothesis 生成任意数据。Hypothesis 会为您管理 Python 和 Numpy 的随机种子,并提供一种非常简洁且强大的描述数据的方法(包括 hypothesis.extra.numpy,例如用于一组可广播形状的集合)。

与单纯的随机生成相比,它的优点包括:无需固定种子即可重现和共享失败的工具、报告每次失败的最小示例,以及比原生随机方法更有效地触发 bug 的技术。

编写线程安全测试#

编写线程安全测试可能需要一些反复试验。通常,您应该遵循目前为止所述的指导原则,尤其是在涉及设置方法和播种随机数据时。显式的设置和使用本地 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模块名称

要测试的模块名称。

属性:
module_namestr

要测试的包的完整路径。

备注

与之前基于 nose 的实现不同,此类并未公开暴露,因为它执行了一些 numpy 特有的警告抑制。