测试 cuDF#
工具#
cuDF 中的测试使用 pytest
编写。测试覆盖率使用 coverage.py
衡量,特别是 pytest-cov
插件。代码覆盖率报告会上传到 Codecov。每个 PR 也会指示它是否增加了或减少了测试覆盖率。
配置 pytest#
Pytest 接受在 多个不同文件 中进行配置,并具有指定的发现和优先顺序。特别注意,没有自动的“包含”机制,一旦找到匹配的配置文件,发现就会停止。
出于偏好,以便所有工具配置都位于同一位置,我们使用基于 pyproject.toml
的配置。给定包的测试配置应该位于该包的 pyproject.toml
文件中。
如果测试不自然地属于某个项目,例如 cudf.pandas
集成测试和 cuDF 基准测试,请使用尽可能靠近测试的 pytest.ini
文件。
测试组织结构#
测试如何组织取决于它们属于以下哪两组:
操作
DataFrame
或Series
等类的自由函数,例如cudf.merge
。上述类的方法。
自由函数的测试应根据文档中的 API 部分进行分组。这将类似功能的测试放在同一模块中。类方法的测试应以相同的方式组织,但这种组织应在与类对应的子目录中。例如,DataFrame
索引的测试应放在 dataframe/test_indexing.py
中。在测试可能由多个共享公共父类(例如 DataFrame
和 Series
都需要 IndexedFrame
测试)的类共享的情况下,测试可以放在与父类对应的目录中。
测试内容#
编写测试#
一般来说,功能必须针对标准情况和异常情况进行测试。标准用例可以使用参数化(使用 pytest.mark.parametrize
)来覆盖。标准用例的测试通常应包含一些覆盖:
不同的 dtypes,包括嵌套 dtypes(尤其是字符串)
混合对象,例如
DataFrame
和Series
之间的二进制操作对标量的操作
验证复杂 API 的所有参数组合,如
cudf.merge
。
以下是一些最常见的异常情况需要测试:
零行
Series
/DataFrame
/Index
零列
DataFrame
全部为 null 的数据
对于字符串或列表 API,空字符串/列表
对于列表 API,包含全部 null 元素或空字符串的列表
对于数字数据
全部为 0。
全部为 1。
包含/全部为无穷大 (inf)
包含/全部为非数字 (nan)
给定精度下的
INT${PRECISION}_MAX
(例如,int32
的2**32
)。
大多数特定 API 也会包含一系列其他情况。
通常,最好为不同的异常情况编写独立的测试。过度的参数化和分支会增加复杂性并模糊测试的目的。通常,异常情况需要特定的断言或其他特殊逻辑,因此最好将其分开。此规则的主要例外是基于与 pandas 比较的测试。由于逻辑通常相同,此类测试可以同时测试异常情况和更典型的情况。
参数化:自定义 fixture 和 pytest.mark.parametrize
#
在使用 pytest
编写测试时进行参数化,主要有两个选项:fixture 和 mark.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_warnings
是 pytest.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
。任何预期失败的测试都应标记为 skipped
或 xfailed
。
例如:
@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 升级期间发生。