测试 cuDF#

工具#

cuDF 中的测试使用 pytest 编写。测试覆盖率使用 coverage.py 衡量,特别是 pytest-cov 插件。代码覆盖率报告会上传到 Codecov。每个 PR 也会指示它是否增加了或减少了测试覆盖率。

配置 pytest#

Pytest 接受在 多个不同文件 中进行配置,并具有指定的发现和优先顺序。特别注意,没有自动的“包含”机制,一旦找到匹配的配置文件,发现就会停止。

出于偏好,以便所有工具配置都位于同一位置,我们使用基于 pyproject.toml 的配置。给定包的测试配置应该位于该包的 pyproject.toml 文件中。

如果测试不自然地属于某个项目,例如 cudf.pandas 集成测试和 cuDF 基准测试,请使用尽可能靠近测试的 pytest.ini 文件。

测试组织结构#

测试如何组织取决于它们属于以下哪两组:

  1. 操作 DataFrameSeries 等类的自由函数,例如 cudf.merge

  2. 上述类的方法。

自由函数的测试应根据文档中的 API 部分进行分组。这将类似功能的测试放在同一模块中。类方法的测试应以相同的方式组织,但这种组织应在与类对应的子目录中。例如,DataFrame 索引的测试应放在 dataframe/test_indexing.py 中。在测试可能由多个共享公共父类(例如 DataFrameSeries 都需要 IndexedFrame 测试)的类共享的情况下,测试可以放在与父类对应的目录中。

测试内容#

编写测试#

一般来说,功能必须针对标准情况和异常情况进行测试。标准用例可以使用参数化(使用 pytest.mark.parametrize)来覆盖。标准用例的测试通常应包含一些覆盖:

  • 不同的 dtypes,包括嵌套 dtypes(尤其是字符串)

  • 混合对象,例如 DataFrameSeries 之间的二进制操作

  • 对标量的操作

  • 验证复杂 API 的所有参数组合,如 cudf.merge

以下是一些最常见的异常情况需要测试:

  1. 零行 Series/DataFrame/Index

  2. 零列 DataFrame

  3. 全部为 null 的数据

  4. 对于字符串或列表 API,空字符串/列表

  5. 对于列表 API,包含全部 null 元素或空字符串的列表

  6. 对于数字数据

  7. 全部为 0。

  8. 全部为 1。

  9. 包含/全部为无穷大 (inf)

  10. 包含/全部为非数字 (nan)

  11. 给定精度下的 INT${PRECISION}_MAX(例如,int322**32)。

大多数特定 API 也会包含一系列其他情况。

通常,最好为不同的异常情况编写独立的测试。过度的参数化和分支会增加复杂性并模糊测试的目的。通常,异常情况需要特定的断言或其他特殊逻辑,因此最好将其分开。此规则的主要例外是基于与 pandas 比较的测试。由于逻辑通常相同,此类测试可以同时测试异常情况和更典型的情况。

参数化:自定义 fixture 和 pytest.mark.parametrize#

在使用 pytest 编写测试时进行参数化,主要有两个选项:fixturemark.parametrize。Fixture 由于是函数,更冗长但也更具自文档性。Fixture 还有一个显著的优势是它们是惰性构造的,而参数化是在测试收集时构造的。

一般来说,这些方法适用于不同复杂度的参数化。在本讨论中,如果参数化由原始对象的列表(可能嵌套)组成,我们将其定义为“简单”。示例包括整数列表或字符串列表的列表。这包括例如 cuDF 或 pandas 对象。特别地,开发者应避免在测试收集期间执行 GPU 内存分配。

考虑到这一点,以下是一些关于如何进行参数化的基本规则。

在以下情况下使用 pytest.mark.parametrize

  • 一个测试必须在许多输入上运行,并且这些输入易于构造。

在以下情况下使用 fixture:

  • 一个或多个测试必须在同一组输入上运行,并且所有这些输入都可以通过简单的参数化来构造。实际上,这意味着可以接受使用如下 fixture:

        @pytest.fixture(params=["a", "b"])
        def foo(request):
            if request.param == "a":
                # Some complex initialization
            elif request.param == "b":
                # Some other complex initialization
    

    换句话说,fixture 的构造可能很复杂,只要该构造的参数化很简单即可。

  • 一个或多个测试必须在同一组输入上运行,并且至少一个输入需要复杂的参数化。在这种情况下,fixture 的参数化应该通过使用依赖于其他 fixture 的 fixture 进行分解。

        @pytest.fixture(params=["a", "b"])
        def foo(request):
            if request.param == "a":
                # Some complex initialization
            elif request.param == "b":
                # Some other complex initialization
    
        @pytest.fixture
        def bar(foo):
           # do something with foo like initialize a cudf object.
    
        def test_some_property(bar):
            # will be run for each value of bar that results from each value of foo.
            assert some_property_of(bar)
    

复杂参数化#

上面的列表记录了常见的用例。然而,可能会出现更复杂的情况。最常见的替代方案之一是,给定一组测试用例,不同的测试需要在具有非空交集的不同子集上运行。Fixture 和参数化只能处理参数的笛卡尔积,即“对 a 的所有值和 b 的所有值运行此测试”。

解决此问题有多种潜在方案。一种可能性是将通用测试逻辑封装在辅助函数中,然后从构造必要输入的多个 test_* 函数中调用它。另一种可能性是使用函数而不是 fixture 来构造输入,从而允许更灵活的输入构造:

def get_values(predicate):
    values = range(10)
    yield from filter(predicate, values)

def test_evens():
    for v in get_values(lambda x: x % 2 == 0):
        # Execute test

def test_odds():
    for v in get_values(lambda x: x % 2 == 1):
        # Execute test

其他方法也是可能的,最佳解决方案应在 PR 审查期间根据具体情况进行讨论。

带有预期失败 (xfail) 的测试#

在某些情况下,将测试标记为*预期*失败是合理的,例如,因为该功能尚未在 cuDF 中实现。为此,请在测试上使用 pytest.mark.xfail fixture。

如果测试已参数化,并且只有一个参数预计会失败,则不要将整个测试标记为 xfail,而是通过创建带有适当标记的 pytest.param 来标记单个参数。

@pytest.mark.parametrize(
    "value",
    [
        1,
        2,
        pytest.param(
            3, marks=pytest.mark.xfail(reason="code doesn't work for 3")
        ),
    ],
)
def test_value(value):
    assert value < 3

标记 xfail 测试时,请提供描述性的原因。这*应该*包含指向描述问题的 issue 的链接,以便跟踪解决问题的进度。如果尚不存在此类 issue,请创建一个!

条件 xfail#

有时,参数化测试仅在某些参数组合下才预期失败。例如,除以零,但仅当数据类型为 bool 时。如果所有带有给定参数的组合都预期失败,可以标记该参数为 pytest.mark.xfail,并说明预期失败的原因。如果只有*部分*组合预期失败,则很容易标记参数为 xfail,但这应该避免。标记为 xfail 但通过的测试被视为“意外通过”或 XPASS,由于我们使用 pytest 选项 xfail_strict=true,这被测试套件视为失败。另一种选择是在测试体中使用编程方式的 pytest.xfail 函数来 xfail 相关参数组合。**请勿使用此选项**。与基于标记的方法不同,pytest.xfail *不会*运行测试体的其余部分,因此我们永远无法得知测试是否因为 bug 已修复而开始通过。通过预提交 hook 检查并禁止使用 pytest.xfail

相反,要处理这种(希望罕见的)情况,我们可以通过将 pytest.mark.xfail 标记应用到当前测试 request 上,以编程方式将测试标记为在某些条件组合下预期失败。为此,测试函数应接受一个名为 request 的额外参数,然后我们调用其 applymarker 方法:

@pytest.mark.parametrize("v1", [1, 2, 3])
@pytest.mark.parametrize("v2", [1, 2, 3])
def test_sum_lt_6(request, v1, v2):
    request.applymarker(
        pytest.mark.xfail(
            condition=(v1 == 3 and v2 == 3),
            reason="Add comment linking to relevant issue",
        )
    )
    assert v1 + v2 < 6

这样,当 bug 被修复时,测试套件会在此时失败(我们会记住更新测试)。

测试会引发警告的代码#

某些代码可能会引发警告。一个常见的例子是当 cudf API 被标记为将来移除而弃用时,但也存在许多其他可能性。cudf 测试套件将所有警告视为错误。这包括从非 cudf 代码(例如调用 pandas 或 pyarrow)引发的警告。此设置强制开发者主动处理来自其他库的弃用,并防止在库的其他部分内部使用弃用的 cudf API。同样重要的是,它可以帮助捕获实际错误,例如整数溢出或除以零。

测试预期会引发警告的代码时,开发者应使用 pytest.warns 上下文来捕获警告。对于在特定条件下引发警告的参数化测试,请使用 testing._utils.expect_warning_if 装饰器而不是 pytest.warns

警告

warnings.catch_warningspytest.warns 的一个诱人替代方案。**请勿在测试中使用此上下文管理器。**与 pytest.warns 要求必须引发预期警告不同,warnings.catch_warnings 只是捕获出现的警告,而不需要它们。cudf 测试套件应避免此类歧义。

测试实用函数#

cudf.testing 子包提供了一些用于测试对象相等性的实用工具。内部 cudf.testing._utils 模块为测试中使用的辅助函数提供了额外功能。特别是:

  • testing._utils.assert_eq 是最强大的工具。它可以用于比较任意一对对象。

  • 要比较特定对象,请使用 testing.testing.assert_[frame|series|index]_equal

  • 要验证是否引发了预期的断言,请使用 testing._utils.assert_exceptions_equal

版本测试#

建议 cudf 的 pytest 仅在最新支持的 pandas 版本上工作,即 PANDAS_CURRENT_SUPPORTED_VERSION。任何预期失败的测试都应标记为 skippedxfailed

例如:

@pytest.mark.skipif(PANDAS_VERSION < PANDAS_CURRENT_SUPPORTED_VERSION, reason="bug in older version of pandas")
def test_bug_from_older_pandas_versions(...):
    ...

@pytest.mark.xfail(PANDAS_VERSION >= PANDAS_CURRENT_SUPPORTED_VERSION, reason="bug in latest version of pandas")
def test_bug_in_current_and_maybe_future_versions(...):
    ...

如果 pandas 发布了 bug 修复版本并修复了此问题,我们将在 CI 中立即看到它,进行修补,并提高 PANDAS_CURRENT_SUPPORTED_VERSION,这通常在 pandas 升级期间发生。