编写测试

对于像 SymPy 这样的数学库来说,最重要的事情是正确性。函数绝不应该返回数学上不正确的结果。正确性始终是首要关注点,即使这意味着牺牲性能或模块性。

因此,SymPy 中的所有功能都经过了广泛的测试。本指南介绍了如何在 SymPy 中编写测试。

测试政策

为了确保正确性的高标准,SymPy 对所有拉取请求有以下规则:

  1. 所有新功能必须经过测试。测试应旨在覆盖所有可能的情况,以最好地确保正确性。这意味着不仅要最大化代码覆盖率,还要覆盖所有可能的边缘情况。

  2. 每个拉取请求在合并之前必须通过所有测试。测试由GitHub Actions CI在每个拉取请求上自动运行。如果有任何测试失败,CI将以红色❌失败。在拉取请求可以合并之前,必须解决这些失败。

  3. Bug 修复应伴随一个 回归测试

编写测试的基础

测试位于 tests/ 目录中的代码旁边,文件名为 test_<thing>.py。在大多数情况下,如果你修改了 sympy/<子模块>/<文件>.py,那么功能的测试将放在 sympy/<子模块>/tests/test_<文件>.py 中。例如,sympy/simplify/sqrtdenest.py 中的函数的测试位于 sympy/simplify/tests/test_sqrtdenest.py 中。这条规则有一些例外,因此通常情况下,尝试找到现有测试的位置,并将你的测试添加到它们旁边。如果你正在为新函数添加测试,请遵循你正在添加的模块中的测试的一般模式。

测试遵循一个简单的模式,从阅读现有的测试文件中可以明显看出。测试位于以 test_ 开头的函数中,并包含类似以下的行。

assert function(arguments) == result

例如

# from sympy/functions/elementary/tests/test_trigonometric.py

def test_cos_series():
    assert cos(x).series(x, 0, 9) == \
        1 - x**2/2 + x**4/24 - x**6/720 + x**8/40320 + O(x**9)

如果相关,可以将新的测试用例添加到现有的测试函数中,或者您可以创建一个新的测试函数。

运行测试

运行测试的基本方法是使用

./bin/test

运行测试,并且

./bin/doctest

运行 doctests。请注意,完整的测试套件可能需要一些时间来运行,因此通常您应该只运行测试的一个子集,例如,对应于您修改的模块。您可以通过将子模块或测试文件的名称传递给测试命令来执行此操作。例如,

./bin/test solvers

将仅运行求解器的测试。

如果你想,你也可以使用 pytest 来运行测试,而不是使用 ./bin/test 工具,例如

pytest -m 'not slow' sympy/solvers

另一个选项是将你的代码推送到GitHub,让CI运行测试。GitHub Actions CI将运行所有测试。然而,完成这些测试可能需要一些时间,因此通常建议在提交之前至少运行基本测试,以避免等待。

在 GitHub Actions 上调试测试失败

当你在CI上看到测试失败时,例如

_____________________________________________________________________________________________________
_________________ sympy/printing/pretty/tests/test_pretty.py:test_upretty_sub_super _________________
Traceback (most recent call last):
  File "/home/oscar/current/sympy/sympy.git/sympy/printing/pretty/tests/test_pretty.py", line 317, in test_upretty_sub_super
    assert upretty( Symbol('beta_1_2') ) == 'β₁₂'
AssertionError

_________________ 之间的部分是测试的名称。你可以通过复制并粘贴以下内容在本地重现测试:

./bin/test sympy/printing/pretty/tests/test_pretty.py::test_upretty_sub_super

pytest sympy/printing/pretty/tests/test_pretty.py::test_upretty_sub_super

测试还会显示断言失败的文件和行号(在本例中,是 sympy/printing/pretty/tests/test_pretty.py 中的第 317 行),因此您可以查找以查看测试的内容。

有时当你这样做时,你可能无法在本地重现测试失败。这种情况的一些常见原因是:

  • 你可能需要将最新的 master 合并到你的分支中以重现故障(GitHub Actions 在运行测试之前总会将你的分支与最新的 master 合并)。

  • CI 测试环境的某些方面可能与您的不同(尤其是依赖于 可选依赖项 的测试)。请检查 CI 日志顶部安装的相关包的版本。

  • 在你之前的某些其他测试可能会以某种方式影响你的测试。SymPy不应该有全局状态,但有时某些状态可能会意外地潜入。检查这一点的唯一方法是运行在CI上运行的完全相同的测试命令。

  • 一个测试可能会偶尔失败。尝试多次重新运行测试。CI上的测试日志开头会打印随机种子,可以传递给 ./bin/test --seed,以及 PYTHONHASHSEED 环境变量,这些可能有助于重现此类失败。

有时,CI 上的失败可能与您的分支无关。我们只合并通过 CI 的分支,以便主分支始终理想地通过测试。但有时可能会出现失败。通常这要么是因为失败是偶发的(见前一条),并且未被注意到,要么是因为某些 可选依赖 被更新,导致可选依赖测试失败。如果测试失败看起来与您的更改无关,请检查 主分支的 CI 构建 以及最近其他 PR 的 CI 构建是否存在相同的失败。如果存在,这很可能是这种情况。如果不存在,您应该更仔细地检查您的更改是否导致了失败,即使它看起来无关。

当主分支出现CI失败时,请注意,在问题修复之前,您的拉取请求无法合并。这不是必须的,但如果您知道如何修复,请这样做以帮助大家(如果您这样做,请在单独的拉取请求中进行,以便可以迅速合并)。

回归测试

回归测试是那些在修复错误之前会失败,但现在通过的测试。通常你可以使用问题中的代码示例作为测试用例,尽管简化这些示例或编写自己的示例也是可以的,只要它测试了相关问题。

例如,考虑 issue #21177,它发现了以下错误结果:

>>> residue(cot(pi*x)/((x - 1)*(x - 2) + 1), x, S(3)/2 - sqrt(3)*I/2) 
-sqrt(3)*tanh(sqrt(3)*pi/2)/3
>>> residue(cot(pi*x)/(x**2 - 3*x + 3), x, S(3)/2 - sqrt(3)*I/2) 
0

这里第一个表达式是正确的,但第二个不是。在问题中,问题的原因在 as_leading_term 方法中被识别出来,并且还发现了其他几个相关问题。

在相应的拉取请求(#21253)中,添加了几个回归测试。例如(来自该PR):

# In sympy/functions/elementary/tests/test_trigonometric.py

def test_tan():
    ...
    # <This test was already existing. The following was added to the end>

    # https://github.com/sympy/sympy/issues/21177
    f = tan(pi*(x + S(3)/2))/(3*x)
    assert f.as_leading_term(x) == -1/(3*pi*x**2)
# In sympy/core/tests/test_expr.py

def test_as_leading_term():
    ...
    # <This test was already existing. The following was added to the end>

    # https://github.com/sympy/sympy/issues/21177
    f = -3*x + (x + Rational(3, 2) - sqrt(3)*S.ImaginaryUnit/2)**2\
        - Rational(3, 2) + 3*sqrt(3)*S.ImaginaryUnit/2
    assert f.as_leading_term(x) == \
        (3*sqrt(3)*x - 3*S.ImaginaryUnit*x)/(sqrt(3) + 3*S.ImaginaryUnit)

    # https://github.com/sympy/sympy/issues/21245
    f = 1 - x - x**2
    fi = (1 + sqrt(5))/2
    assert f.subs(x, y + 1/fi).as_leading_term(y) == \
        (-36*sqrt(5)*y - 80*y)/(16*sqrt(5) + 36)
# In sympy/series/tests/test_residues.py

def test_issue_21177():
    r = -sqrt(3)*tanh(sqrt(3)*pi/2)/3
    a = residue(cot(pi*x)/((x - 1)*(x - 2) + 1), x, S(3)/2 - sqrt(3)*I/2)
    b = residue(cot(pi*x)/(x**2 - 3*x + 3), x, S(3)/2 - sqrt(3)*I/2)
    assert a == r
    assert (b - a).cancel() == 0

此示例展示了回归测试的一些重要方面:

  • 应该为底层修复添加测试,而不仅仅是针对最初报告的问题。在这个例子中,最初报告的问题是关于 residue() 函数,但底层问题在于 as_leading_term() 方法。

  • 同时,添加一个针对所报告的高层次问题的测试也可能是有益的。这确保了 residue 本身在未来不会被破坏,即使其实现细节发生了变化,以至于不再使用被修复的相同代码路径。

  • 这个例子没有展示这一点,但在某些情况下,为测试用例简化最初报告的问题可能是明智的。例如,有时用户会在报告中包含不必要的细节,这些细节实际上对问题的重现无关紧要(比如对符号的不必要假设),或者使输入表达式过大或包含太多不必要的常量符号。如果最初报告的问题中的代码计算速度慢,这一点尤其重要。如果可以用运行更快的测试来测试同样的事情,那么这应该是首选。

  • 还应为在问题中识别出的其他错误添加回归测试。在这个例子中,第二个测试(添加到 test_as_leading_term() 的测试)在 问题评论 中被识别为相关问题。

  • 在回归测试中交叉引用问题编号是有用的,可以通过注释或在测试名称中实现。如果测试是添加到现有测试中的,则首选注释。

回归测试不仅仅用于修复错误。它们也应该用于新功能,以确保新实现的功能保持实现且正确。

特殊类型的测试

大多数测试的形式将是 assert function(input) == output。然而,还有其他类型的内容你可能想要测试,这些内容应该以特定的方式进行测试。

测试异常

要测试一个函数是否引发给定的异常,请使用 sympy.testing.pytest.raisesraises() 接受一个异常类和一个 lambda。例如

from sympy.testing.pytest.raises
raises(TypeError, lambda: cos(x, y)

记得包含 lambda。否则,代码将立即执行并引发异常,导致测试失败。

# BAD
raises(TypeError, cos(x, y)) # This test will fail

raises 也可以用作上下文管理器,例如

with raises(TypeError):
    cos(x, y)

然而,使用这种形式时要小心,因为它一次只能检查一个表达式。如果上下文管理器下的代码引发多个异常,只有第一个异常会被实际测试。

# BAD
with raises(TypeError):
   cos(x, y)
   sin(x, y) # THIS WILL NEVER BE TESTED

lambda 形式通常更好,因为它避免了这个问题,尽管如果你正在测试的东西不能用 lambda 表示,你需要使用上下文管理器形式。

测试警告

警告 可以通过 sympy.testing.pytest.warns() 上下文管理器进行测试。请注意,SymPyDeprecationWarning 是特殊的,应该使用 warns_deprecated_sympy() 进行测试(见下文)。

上下文管理器应接受一个警告类(warnings.warn() 默认使用 UserWarning),并且可选地,一个正则表达式,警告消息应与之匹配,作为 match 关键字参数。

from sympy.testing.pytest import warns
with warns(UserWarning):
    function_that_emits_a_warning()

with warns(UserWarning, match=r'warning'):
    function_that_emits_a_warning()

任何发出警告的测试功能都应该使用 warns() 这样,在测试过程中实际上不会发出任何警告。这包括来自外部库的警告。

在 SymPy 内部,警告的使用应该非常谨慎。除了 弃用警告 之外,警告通常不在 SymPy 中使用,因为它们可能对用户来说太烦人了,尤其是那些将 SymPy 用作库的用户,不值得这样做。

当你确实使用它们时,你必须在警告中设置 stacklevel 参数,以便它显示调用发出警告的函数的用户代码。如果 stacklevel 参数无法正确设置,请使用 warns(test_stacklevel=False) 来禁用 warns 中对 stacklevel 正确使用的检查。如果这适用于 SymPyDeprecationWarning,则必须使用 warns(SymPyDeprecationWarning, test_stacklevel=False) 代替 warns_deprecated_sympy()

测试已弃用的功能

已弃用的功能应使用 sympy.testing.pytest.warns_deprecated_sympy() 上下文管理器进行测试。

这个上下文管理器的唯一目的是测试弃用警告本身是否正常工作。这应该是测试套件中唯一调用已弃用功能的地方。所有其他测试应使用非弃用功能。如果无法避免使用已弃用功能,这可能表明该功能实际上不应被弃用。

弃用策略 页面详细介绍了如何向函数添加弃用。

例如,

from sympy.testing.pytest import warns_deprecated_sympy
x = symbols('x')

# expr_free_symbols is deprecated
def test_deprecated_expr_free_symbols():
    with warns_deprecated_sympy():
        assert x.expr_free_symbols == {x}

如果代码使用了来自另一个库的已弃用功能,应更新此代码。在此之前,应在相应的测试中使用正常的 warns() 上下文管理器,以防止警告被发出。

测试某事物未被改变

正常的测试风格

assert function(input) == output

适用于大多数类型的测试。然而,在SymPy对象应保持不变的情况下,它不起作用。考虑以下示例:

assert sin(pi) == 0
assert sin(pi/2) == 1
assert sin(1) == sin(1)

这里的前两个测试是正常的。测试 sin 对于输入 pipi/2 返回相应的特殊值。然而,最后一个测试名义上检查 sin(1) 是否不返回任何值。但仔细检查后,我们发现它根本没有做到这一点。sin(1) 实际上可以返回任何值。它可能返回完全无意义的结果,甚至是像 0 这样的错误答案。测试仍然会通过,因为它所做的只是检查 sin(1) 的结果是否等于 sin(1) 的结果,只要它总是返回相同的东西,这一点总是成立的。

我们确实想检查 sin(1) 保持未计算状态。sympy.core.expr.unchanged 助手将完成此任务。

像这样使用它

from sympy.core.expr import unchanged

def test_sin_1_unevaluated():
    assert unchanged(sin, 1)

这个测试现在实际上检查了正确的内容。如果 sin(1) 被设置为返回某个值,测试将会失败。

使用 Dummy 测试表达式

返回 Dummy 的表达式不能直接用 == 进行测试,这是由于 Dummy 的特性。在这种情况下,使用 dummy_eq() 方法。例如:

# from
sympy/functions/combinatorial/tests/test_comb_factorials.py

def test_factorial_rewrite():
    n = Symbol('n', integer=True)
    k = Symbol('k', integer=True, nonnegative=True)

    assert factorial(n).rewrite(gamma) == gamma(n + 1)
    _i = Dummy('i')
    assert factorial(k).rewrite(Product).dummy_eq(Product(_i, (_i, 1, k)))
    assert factorial(n).rewrite(Product) == factorial(n)

一致性检查

检查一组已知的输入和输出只能让你走这么远。像这样的测试

assert function(input) == expression

将检查 function(input) 返回 expression,但它不会检查 expression 本身在数学上是否正确。

然而,根据 function 是什么,有时可以进行一致性检查,以检查 expression 本身是否正确。这通常归结为“以两种不同的方式计算 expression”。如果两种方式一致,那么它很可能是正确的,因为两种完全不同的方法不太可能产生相同的错误答案。

例如,不定积分的逆运算是微分。可以通过检查结果的导数是否产生原始被积函数来验证积分的正确性:

expr = sin(x)*exp(x)
expected == exp(x)*sin(x)/2 - exp(x)*cos(x)/2

# The test for integrate()
assert integrate(expr, x) == expected
# The consistency check that the test itself is correct
assert diff(expected, x) == expr

diff 的实现相比于 integrate 非常简单,并且它是单独测试的,因此这确认了答案是正确的。

当然,也可以手动确认答案,这也是SymPy中大多数测试所做的。但一致性检查并无害处,尤其是当它很容易做到时。

在 SymPy 测试套件中使用一致性检查本身并不一致。一些模块大量使用它们,例如,ODE 模块中的每个测试都使用 checkodesol() 进行自我检查。其他模块在测试中根本不使用一致性检查,尽管其中一些可以更新以使用它们。在某些情况下,没有合理的一致性检查,必须使用其他来源的真理来验证测试输出。

当大量使用一致性检查时,通常最好将逻辑分解到测试文件中的辅助函数中,以避免重复。辅助函数应以一个下划线开头,这样测试运行器就不会误认为它们是测试函数。

随机测试

另一种测试可以自我检查一致性的方法是检查随机数值输入上的表达式。sympy.core.random 中的辅助函数可以用于此目的。请参阅 sympy/functions/special/ 中的测试,这些测试大量使用了此功能。

如果你添加了一个随机测试,请务必多次运行该测试以确保它总是通过。随机测试可以通过使用测试顶部打印的随机种子来重现。例如

$./bin/test
========================================================================== test process starts ==========================================================================
executable:         /Users/aaronmeurer/anaconda3/bin/python  (3.9.13-final-0) [CPython]
architecture:       64-bit
cache:              yes
ground types:       gmpy 2.1.2
numpy:              1.22.4
random seed:        7357232
hash randomization: on (PYTHONHASHSEED=3923913114)

这里的随机种子是 7357232。它可以被重现。

./bin/test --seed 7357232

通常,您可能需要使用与测试头中显示的相同Python版本和架构来重现随机测试失败。在某些情况下,您可能还需要使用完全相同的输入参数(即,运行完整的测试套件或仅运行子集)来运行测试,以便重现随机失败的测试。

跳过测试

可以使用 sympy.testing.pytest.SKIP 装饰器或 sympy.testing.pytest.skip() 函数跳过测试。请注意,因为预期会失败的测试应使用 @XFAIL 装饰器(参见下方)。因为测试太慢而跳过的测试应使用 @slow 装饰器

应避免无条件跳过的测试。这样的测试几乎完全无用,因为它永远不会实际运行。唯一无条件跳过测试的原因是,如果它本应是 @XFAIL@slow,但由于某些原因不能使用这些装饰器。

@SKIP()skip() 都应该包含一个解释为什么跳过测试的消息,例如 skip('numpy 未安装')

跳过测试的典型用法是当测试依赖于一个 可选依赖 时。

此类测试通常编写为

from sympy.external import import_module

# numpy will be None if NumPy is not installed
numpy = import_module('numpy')

def test_func():
    if not numpy:
       skip('numpy is not installed')

    assert func(...) == ...

当测试以这种方式编写时,当未安装 NumPy 时,测试不会失败,这一点很重要,因为 NumPy 不是 SymPy 的硬依赖项。另请参见下面的 使用外部依赖编写测试

将测试标记为预期失败

SymPy 中的一些测试预期会失败。它们是这样编写的:当检查的功能最终实现时,已经为此编写了一个测试。

预期会失败的测试被称为 XFAIL 测试。当它们如预期失败时,在测试运行器中显示为 f,当它们通过时显示为 X(或 “XPASS”)。一个 XPASS 的测试应该移除其 @XFAIL 装饰器,以便它成为一个普通测试。

要使测试 XFAIL,请为其添加 sympy.testing.pytest.XFAIL 装饰器。

from sympy.testing.pytest import XFAIL

@XFAIL
def test_failing_integral():
    assert integrate(sqrt(x**2 + 1/x**2), x) == x*sqrt(x**2 + x**(-2))*(sqrt(x**4 + 1) - atanh(sqrt(x**4 + 1)))/(2*sqrt(x**4 + 1))

在编写XFAIL测试时应小心,以确保当功能开始工作时它实际上能通过。例如,如果你输错了输出,测试可能永远不会通过。例如,上述测试中的积分可能会开始工作,但返回的结果形式可能与正在检查的形式略有不同。一个更健壮的测试将是

from sympy.testing.pytest import XFAIL

@XFAIL
def test_failing_integral():
    # Should be x*sqrt(x**2 + x**(-2))*(sqrt(x**4 + 1) - atanh(sqrt(x**4 + 1)))/(2*sqrt(x**4 + 1))
    assert not integrate(sqrt(x**2 + 1/x**2), x).has(Integral)

这将导致测试在积分开始工作时通过,届时可以更新测试以包含 integrate() 的实际输出(可以与预期输出进行比较)。

将测试标记为慢速

运行缓慢的测试应使用 sympy.testing.pytest.slow 中的 @slow 装饰器标记。@slow 装饰器应用于运行时间超过一分钟的测试。挂起的测试应使用 @SKIP 而不是 @slow。缓慢的测试将在单独的 CI 作业中自动运行,但默认情况下会被跳过。您可以手动运行缓慢的测试。

./bin/test --slow

使用外部依赖编写测试

在为一个使用 SymPy 的 可选依赖项 的函数编写测试时,测试应以这样一种方式编写,即当模块未安装时,测试不会失败。

实现这一目标的方法是使用 sympy.external.import_module()。如果模块已安装,这将导入模块并返回 None

sympy.testing.pytest.skip 应在相关模块未安装时用于跳过测试(见上文 跳过测试)。如果整个测试文件应被跳过,可以在模块级别进行此操作,或者在每个单独的函数中进行。

你还应该确保测试在“可选依赖”CI运行中执行。为此,编辑 bin/test_optional_dependencies.py 并确保测试被包含在内(大多数测试可选依赖的SymPy子模块已经自动包含)。

如果可选依赖是新的,请将其添加到 .github/workflows/runtests.yml 中可选依赖构建的包列表中,并将其添加到 doc/src/contributing/dependencies.md 的可选依赖文档中。

请注意,在使用 mpmath 时,不需要做任何这些操作,因为它已经是 SymPy 的 硬依赖,并且总是会被安装。

文档测试

每个公共函数都应该有一个文档字符串,每个文档字符串都应该有示例。代码示例都是经过测试的,这就是为什么它们有时也被称为 doctests文档字符串风格指南 提供了更多关于如何在文档字符串中格式化示例的详细信息。

要运行doctests,请使用

./bin/doctest

这个命令也可以接受参数来测试特定的文件或子模块,类似于 bin/test

Doctests 应以自包含的方式编写,每个 doctest 都像是一个全新的 Python 会话。这意味着每个 doctest 必须手动导入在 doctest 中使用的每个函数,并定义使用的符号。这可能看起来冗长,但对于刚接触 SymPy 甚至 Python 的用户来说很有帮助,他们可能不知道不同的函数来自哪里。这也使得用户可以轻松地将示例复制并粘贴到自己的 Python 会话中(HTML 文档在每个代码示例的右上角包含一个按钮,可以将整个示例复制到剪贴板)。

例如

>>> from sympy import Function, dsolve, cos, sin
>>> from sympy.abc import x
>>> f = Function('f')
>>> dsolve(cos(f(x)) - (x*sin(f(x)) - f(x)**2)*f(x).diff(x),
...        f(x), hint='1st_exact')
Eq(x*cos(f(x)) + f(x)**3/3, C1)

doctest 的输出应该看起来与在 python 会话中的完全一样,输入前有 >>>,输出后有结果。doctest 测试输出字符串是否匹配,这与通常检查 Python 对象是否相同的普通测试不同,普通测试通常使用 == 进行检查。因此,输出需要看起来与 Python 会话中的 完全 相同。

与测试类似,所有 doctest 必须通过才能接受更改。然而,在编写 doctest 时,重要的是要记住 doctest 不应被视为测试。相反,它们是恰好被测试的示例。

因此,在编写doctests时,您应该始终考虑什么将构成一个良好、可读的示例。doctests不需要广泛覆盖所有可能的输入,也不应包括边缘或极端情况,除非它们对用户来说很重要。

在 doctest 中测试的所有内容也应该在 常规测试 中进行测试。如果 doctest 示例改进了文档,您应该随时可以自由地删除或更改它(相比之下,除非在 某些特殊情况下,否则常规测试不应更改或删除)。

这也意味着doctests应该首先以一种使阅读文档的人能够理解的方式编写。有时可能会倾向于以某种间接的方式编写doctest以取悦doctester,但如果这使得示例更难理解,则应避免这种情况。例如

# BAD
>>> from sympy import sin, cos, trigsimp, symbols
>>> x = symbols('x')
>>> result = trigsimp(sin(x)*cos(x))
>>> result == sin(2*x)/2
True

这通过了doctest,类似这样的内容在常规测试中也是可以的。但在docstring示例中,直接展示实际输出会更加清晰。

# BETTER
>>> from sympy import sin, cos, trigsimp, symbols
>>> x = symbols('x')
>>> trigsimp(sin(x)*cos(x))
sin(2*x)/2

当然,在某些情况下,完整的输出可能过于庞大,显示它会使得示例更难阅读,因此这种做法可能是合适的。请运用你的最佳判断,记住作为文档示例的doctest的可理解性是最重要的。在某些极端情况下,可能更倾向于跳过测试一个示例(参见下方),而不是以一种复杂的方式编写它,仅仅是为了取悦doctester而使其难以阅读。

以下是一些编写doctests的额外提示:

  • 长输入行可以通过使用 ... 作为续行提示符分成多行,如上例所示。doctest 运行器还允许长输出行换行(它在输出中忽略换行符)。

  • 常见的符号名称可以从 sympy.abc 导入。不常见的符号名称或使用假设的符号应使用 symbols 定义。

    >>> from sympy.abc import x, y
    >>> x + y
    x + y
    
    >>> from sympy import symbols, sqrt
    >>> a, b = symbols('a b', positive=True)
    >>> sqrt((a + b)**2)
    a + b
    
  • 如果测试显示了一个回溯,Traceback (most recent call last): 和包含异常消息的最后一行之间的所有内容应该被替换为 ...,例如

    >>> from sympy import Integer
    >>> Integer('a')
    Traceback (most recent call last):
    ...
    ValueError: invalid literal for int() with base 10: 'a'
    
  • ... 是特殊的,因为每当它在示例的输出中出现时,doctester 将允许它替换任意数量的文本。在每次运行时输出不一致的情况下,也应该使用它,例如

    >>> from sympy import simplify
    >>> simplify
    <function simplify at ...>
    

    这里的实际输出类似于 <function simplify at 0x10e997790> ,但 0x10e997790 是一个内存地址,每次Python会话都会不同。

    在输出中应谨慎使用 ...,因为它会阻止 doctest 实际检查输出的一部分。这还可能使文档的读者不清楚其含义。请注意,如果 doctest 的输出在未来更新为其他内容,这是可以的。不应使用 ... 来试图“为未来做准备” doctest 输出。另请注意,doctester 已经自动处理了输出中仅空白差异和浮点值等问题。

  • 你可以在输出行中换行。doctester 会自动忽略输出中仅包含空白字符的差异,这包括换行符。长行应该被拆分,以便它们在 HTML 文档中不会超出页面(并且源代码中的行不会超过 80 个字符)。例如:

    >>> ((x + 1)**10).expand()
    x**10 + 10*x**9 + 45*x**8 + 120*x**7 + 210*x**6 + 252*x**5 + 210*x**4 +
    120*x**3 + 45*x**2 + 10*x + 1
    
  • 如果 doctest 无法通过,另一个选项是跳过它,通过在输入行的末尾添加 # doctest:+SKIP,例如

    >>> import random
    >>> random.random()      # doctest: +SKIP
    0.6868680200532414
    

    # doctest:+SKIP 部分在 HTML 文档中会自动隐藏。当跳过 doctest 时,务必手动测试输出,因为 doctester 不会为你检查它。

    # doctest:+SKIP 应该谨慎使用。理想情况下,只有在无法运行时才应跳过 doctest。被跳过的 doctest 将永远不会被测试,这意味着它可能会变得过时(即不正确),这会让用户感到困惑。

  • 需要依赖项才能运行的Doctests不应使用 # doctest: +SKIP 跳过。相反,应该在函数上使用 @doctest_depends_on 装饰器来指示运行doctest需要安装哪些库。

  • 如果测试输出包含一个空行,请使用 <BLANKLINE> 代替空行。否则,doctester 会认为输出在空行处结束。<BLANKLINE> 将在 HTML 文档中自动隐藏。这并不常见,因为大多数 SymPy 对象不会打印出空行。

  • 避免在 doctest 示例中使用 pprint()。如果需要以更易读的方式展示表达式,可以使用美元符号将其作为 LaTeX 数学公式内联包含。如果绝对必须使用 pprint(),请始终使用 pprint(use_unicode=False),因为用于美化打印的 Unicode 字符在 HTML 文档中并不总是正确渲染。

  • 如果你想展示某个东西返回 None,使用 print,例如

    >>> from sympy import Symbol
    >>> x = Symbol('x', positive=True)
    >>> x.is_real
    True
    >>> x = Symbol('x', real=True)
    >>> x.is_positive # Shows nothing, because it is None
    >>> print(x.is_positive)
    None
    
  • 你可以在 doctest 中添加简短的注释,既可以放在行尾,也可以单独放在 >>> 之后。不过,这些注释通常应该只有几个词长。doctest 中发生的事情的详细解释应该放在周围的文本中。

  • 字典和集合由doctester自动排序,任何表达式也会自动排序,以确保术语的顺序始终以相同的方式打印。通常,您只需包含doctester“期望”的输出,它将始终随后通过。

    >>> {'b': 1, 'a': 2}
    {'a': 2, 'b': 1}
    >>> {'b', 'a'}
    {'a', 'b'}
    >>> y + x
    x + y
    

更新现有测试

有时当你修改某些内容或修复一个错误时,一些现有的测试会失败。如果发生这种情况,你应该检查测试以了解它失败的原因。在许多情况下,测试会检查你没有考虑到的事情,或者你的修改产生了意外的副作用,导致其他东西被破坏。当这种情况发生时,你可能需要重新审视你的修改。如果你不确定该怎么做,你应该在问题或拉取请求中讨论它。

如果失败的测试是 代码质量测试,通常这意味着你只需要修复你的代码,使其满足代码质量检查(例如,删除尾随空白)。

然而,偶尔可能会发生测试失败但没有任何问题的情况。在这种情况下,应该更新测试。最常见的例子是测试检查特定表达式,但函数现在返回一个不同的、但数学上等价的表达式。这在 doctests 中尤其常见,因为它们不仅检查输出表达式,还检查其打印方式。

如果一个函数的输出在数学上是等价的,可以使用新的输出来更新现有的测试。然而,即使在这种情况下,你也应该小心:

  • 仔细检查新的输出是否确实相同。手动检查例如旧表达式和新表达式的差异是否简化为0。有时,两个表达式在某些假设下是等价的,但并非对所有情况都如此,因此要检查这两个表达式对所有复数是否真的相同。这种情况尤其可能发生在涉及平方根或其他根号的表达式中。你可以检查随机数,或者使用 equals() 方法来完成这项工作。

  • 如果新的输出比旧的输出复杂得多,那么即使它们在数学上是等价的,更新测试可能不是一个好主意。相反,你可能需要调整更改,以便函数仍然返回更简单的结果。

  • 虽然不常见,但有时可能会发现现有的测试本身是错误的。如果测试明显错误,应该直接删除并更新。

无论如何,在更新现有测试时,您应该始终在提交消息或拉取请求评论中解释这样做的理由。不要在代码注释或文档中解释更改。代码注释和文档应仅指代码的当前状态。更改的讨论应属于提交消息或问题跟踪器。讨论代码过去如何的代码注释只会变得令人困惑,并且在更改完成后将不再相关。

再次强调,默认情况下不应更改现有测试。这些测试的存在是有原因的,更改它们首先就违背了拥有它们的初衷。此规则的例外是doctests,如果它们改进了文档,则允许更改或删除,因为doctests的主要目的是为用户提供示例。

代码质量检查

SymPy 有几个必须通过的代码质量检查。在 CI 上运行的第一个作业是代码质量检查。如果此作业失败,则不会运行其他测试。您的 PR 可能会被审查者忽略,直到它们被修复。

代码质量检查都是直接了当的修复。你可以使用本地运行这些检查。

./bin/test quality

flake8 sympy

第二个命令要求你安装 flake8。确保你安装了最新版本的 flake8 及其依赖项 pycodestylepyflakes。有时这些包的较新版本会添加新的检查,如果你安装的是旧版本,你将看不到这些检查。

./bin/test quality 检查用于测试非常基本的代码质量问题。最常见导致测试失败的是行尾空白。行尾空白是指代码行末尾有空格。这些空格没有任何作用,只会导致代码差异被污染。处理行尾空白的最佳方法是配置您的文本编辑器,在保存时自动去除行尾空白。您也可以在 SymPy 仓库中使用 ./bin/strip_whitepace 命令。

flake8 命令将检查代码中是否存在未定义变量等基本代码错误。这些检查受 setup.cfg 中的配置限制,仅检查逻辑错误。通常的 flake8 检查代码风格错误的功能被禁用。在极少数情况下,flake8 警告可能是误报。如果发生这种情况,请在相应的行添加 # noqa: <CODE> 注释,其中 <CODE> 是来自 https://flake8.pycqa.org/en/latest/user/error-codes.html 的错误代码。例如,使用 multipledispatch 的代码需要使用

@dispatch(...)
def funcname(arg1, arg2): # noqa: F811
    ...

@dispatch(...)
def funcname(arg1, arg2): # noqa: F811
    ...

为了避免多次定义同一函数时出现警告。

测试风格指南

在大多数情况下,测试应以与同一测试文件中的周围测试相匹配的方式编写。

在编写测试时,应遵循一些重要的风格要点:

  • 测试函数应以 test_ 开头。如果不这样做,测试运行器将不会测试它们。任何不是测试函数的辅助函数都不应以 test_ 开头。通常最好以一个下划线开头测试辅助函数。如果你发现自己为许多测试文件重复使用相同的辅助函数,请考虑是否应将其移动到 sympy.testing 等地方。

  • 使用与 str() 生成相同的空白格式化表达式(例如,二进制 +- 周围有空格,*** 周围没有空格,逗号后有空格,没有多余的括号等)

  • 避免在测试用例中使用浮点值。除非测试明确地测试函数在浮点输入上的结果,否则测试表达式应使用精确值。

    特别是,避免使用像 1/2 这样的整数除法,它会产生一个浮点值(参见教程的陷阱部分)。例如:

    # BAD
    assert expand((x + 1/2)**2) == x**2 + x + 1/4
    
    # GOOD
    assert expand((x + S(1)/2)**2) == x**2 + x + S(1)/4
    

    如果你确实打算用浮点数值显式地测试一个表达式,请使用浮点数(例如 0.5 而不是 1/2),以明确这是有意为之而非意外。

  • 符号可以在测试文件的顶部或每个测试函数内定义。在测试文件顶部定义的带有假设的符号应以某种方式命名,以明确它们具有假设(例如,xp = Symbol('x', positive=True))。通常最好在每个测试函数内定义带有假设的符号,这样它们就不会意外地在另一个不期望它们具有定义假设的测试中被重用(这通常会改变测试的行为)。

  • 测试文件通常以其测试的代码文件命名(例如,sympy/core/tests/test_symbol.py 包含了对 sympy/core/symbol.py 的测试)。然而,如果存在不精确对应于特定代码文件的测试,这一规则可能会被打破。

  • 避免在测试中使用表达式的字符串形式(显然在打印测试中应该使用字符串;此规则适用于其他类型的测试)。这使得测试依赖于确切的打印输出,而不仅仅是表达式输出。这使得测试更难阅读,并且如果打印机在某些方面发生了变化,测试将需要更新。

    例如:

    # BAD
    assert str(expand((x + 2)**3)) == 'x**3 + 6*x**2 + 12*x + 8'
    
    # GOOD
    assert expand((x + 2)**3) == x**3 + 6*x**2 + 12*x + 8
    

    同样地,不要解析表达式的字符串形式作为输入(除非测试明确要求解析字符串)。直接创建表达式即可。即使这需要创建许多符号或大量使用 S() 来包装有理数,这仍然是更简洁的做法。

    # BAD
    expr = sympify('a*b*c*d*e')
    assert expr.count_ops() == 4
    
    # GOOD
    a, b, c, d, e = symbols('a b c d e')
    expr = a*b*c*d*e
    assert expr.count_ops() == 4
    
  • 在测试假设时使用 is Trueis Falseis None。不要依赖于真值性,因为很容易忘记 None 在 Python 中被视为假。

    # BAD
    assert not x.is_real
    
    # GOOD
    assert x.is_real is False
    

测试覆盖率

要生成测试覆盖率报告,首先安装 coverage.py (例如,使用 pip install coverage)。然后运行

./bin/coverage_report.py

这将运行测试套件并分析代码库中哪些行至少被一个测试覆盖。请注意,这比使用 ./bin/test 正常运行测试需要更长的时间,因为覆盖工具会使 Python 运行得稍微慢一些。你也可以运行测试的子集,例如,./bin/coverage_report.py sympy/solvers

测试完成后,覆盖率报告将在 covhtml 中,您可以通过打开 covhtml/index.html 来查看。每个文件将显示哪些行被测试覆盖(绿色),哪些行未被任何测试覆盖(红色)。

任何未被测试覆盖的行,如果可能的话,都应该为其添加测试。注意,通常情况下100%的覆盖率是不可能的。可能存在一行防御性代码,用于检查是否出现了问题,但这行代码只有在出现bug时才会被触发。或者,可能存在一些功能过于难以测试(例如,与外部依赖接口的代码),或者只有在安装了特定可选依赖时才会触发的功能。然而,如果一行代码可以被测试,那么它就应该被测试。例如,测试文件本身应该达到100%的覆盖率。如果测试文件中的某一行未被覆盖,这通常表明存在错误(参见https://nedbatchelder.com/blog/202008/you_should_include_your_tests_in_coverage.html)。

同样要注意的是,覆盖率并不是故事的全部。虽然一行未测试的代码不能保证其正确性,但一行被覆盖的代码也不能保证其正确性。也许它只针对一般输入进行了测试,而没有针对边缘情况进行测试。有时代码可能有一个条件,比如 if a or b,而 a 在每个测试中总是为真,因此 b 条件从未被测试。当然,仅仅因为一行代码被执行,并不意味着它是正确的。测试需要实际检查函数的输出是否符合预期。测试覆盖率只是确保代码库正确性的一个部分。参见 https://nedbatchelder.com/blog/200710/flaws_in_coverage_measurement.html。

假设检验

现在可以使用 Hypothesis 库创建基于属性的测试。测试应添加到相应 tests 子目录中的 test_hypothesis.py 文件中。如果该文件不存在,请创建一个。以下是模运算的假设测试示例:

from hypothesis import given
from hypothesis import strategies as st
from sympy import symbols
from sympy import Mod


@given(a = st.integers(), p = st.integers().filter(lambda p: p != 0), i = st.integers(),
j = st.integers().filter(lambda j: j != 0))
def test_modular(a, p, i, j):
    x, y = symbols('x y')
    value = Mod(x, y).subs({x: a, y: p})
    assert value == a % p