编写测试¶
对于像 SymPy 这样的数学库来说,最重要的就是正确性。函数永远不应该返回数学上不正确的结果。正确性始终是首要考虑因素,即使这意味着要牺牲性能或模块化。
因此,SymPy 中的所有功能都经过了广泛的测试。本指南介绍了 SymPy 中测试的编写方式。
测试策略¶
为了确保高标准的正确性,SymPy 对所有拉取请求都有以下规则。
所有新功能都必须经过测试。测试的目标应该是涵盖所有可能的情况,以确保正确性。这意味着不仅要最大限度地提高代码覆盖率,还要涵盖所有可能的极端情况。
每个拉取请求都必须通过所有测试才能合并。这些测试会在每个拉取请求上由 GitHub Actions CI 自动运行。如果任何测试失败,CI 将显示红色 ❌。在拉取请求合并之前,必须解决这些失败问题。
错误修复应该附带 回归测试。
编写测试的基础¶
测试代码与代码一起位于 tests/
目录中,并保存在名为 test_<thing>.py
的文件中。在大多数情况下,如果您修改了 sympy/<submodule>/<file>.py
,那么该功能的测试将位于 sympy/<submodule>/tests/test_<file>.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。请注意,完整的测试套件可能需要一些时间才能运行,因此,通常您应该只运行测试的一部分,例如,对应于您修改的模块。您可以通过将子模块或测试文件的名字传递给 test 命令来做到这一点。例如:
./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 的分支,因此 master 始终理想地具有通过的测试。但有时失败会溜进来。这通常是因为失败是零星发生的(参见上一条),而且没有被注意到,或者是因为一些 可选依赖项 被更新,从而破坏了一个可选依赖项测试。如果测试失败似乎与您的更改无关,请检查 master 的 CI 构建,以及最近其他 PR 上的 CI 构建是否具有相同的失败。如果有,这很可能是这种情况。如果没有,您应该更仔细地检查您的更改是否会导致失败,即使它看起来无关。
当 master 分支中出现 CI 失败时,请注意,您的 pull 请求在修复之前无法合并。这不是必需的,但如果您知道如何修复它,请这样做以帮助大家(如果您这样做,请在单独的 pull 请求中执行,以便可以快速合并)。
回归测试¶
回归测试是在修复错误之前会失败,但现在通过的测试。通常,您可以使用来自问题中的代码示例作为测试用例,尽管简化此类示例或编写您自己的示例也是可以的,只要它测试了所讨论的问题即可。
例如,考虑 问题 #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
方法中被识别出来,还发现了几个其他相关问题。
在相应的 pull 请求 (#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.raises
。 raises()
接受一个异常类和一个 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
返回输入 pi
和 pi/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 not installed')
。
跳过测试的典型用法是,当测试依赖于 可选依赖项 时。
此类测试通常以以下方式编写
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
,当它们通过(或“XPASS”)时显示为 X
。一个 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)
这将导致测试在积分开始工作时 XPASS,此时可以使用 integrate()
的实际输出更新测试(这可以与预期输出进行比较)。
将测试标记为慢速¶
如果测试运行速度很慢,应该使用来自sympy.testing.pytest.slow
的@slow
装饰器进行标记。 对于运行时间超过一分钟的测试,应该使用@slow
装饰器。 对于挂起的测试,应该使用@SKIP
而不是@slow
。 慢速测试将在单独的CI作业中自动运行,但默认情况下会跳过。 你可以使用以下命令手动运行慢速测试:
./bin/test --slow
编写带有外部依赖项的测试¶
在为使用 SymPy 的可选依赖项之一的函数编写测试时,应以一种在模块未安装时不会导致测试失败的方式编写测试。
实现此目的的方法是使用sympy.external.import_module()
。 如果已安装该模块,它将导入该模块并返回None
,否则返回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的原因。 文档字符串样式指南详细介绍了如何在文档字符串中格式化示例。
要运行 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
会话中的输出完全一样,在输入之前使用>>>
,输出之后使用>>>
。 Doctester 测试输出字符串是否匹配,这与通常检查 Python 对象是否使用==
相同的普通测试不同。 因此,输出需要看起来与 Python 会话中的输出完全一样。
与测试一样,所有 doctests 都必须通过才能接受更改。 但是,在编写 doctests 时,重要的是要记住,doctests 不应该被认为是测试。 相反,它们是恰好被测试的示例。
因此,在编写 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,并且类似于此的东西将成为正常测试。 但在文档字符串示例中,直接显示实际输出更清楚
# 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
在 HTML 文档中,
# doctest:+SKIP
部分将被自动隐藏。 跳过 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
你可以向 doctests 添加简短注释,在行尾添加或在
>>>
之后独立添加。 但是,这些注释通常应该只有几个词长。 关于 doctest 中发生的事情的详细解释应该放在周围的文本中。字典和集合会由 doctester 自动排序,任何表达式也会自动排序,以便始终以相同的方式打印项的顺序。 通常,你可以只包含 doctester“期望”的输出,它将始终随后通过。
>>> {'b': 1, 'a': 2} {'a': 2, 'b': 1} >>> {'b', 'a'} {'a', 'b'} >>> y + x x + y
更新现有测试¶
有时当你更改代码或修复错误时,一些现有的测试会失败。如果发生这种情况,你应该检查测试以了解它为什么失败。在很多情况下,测试会检查你没有考虑到的东西,或者你的更改产生了意外的副作用,导致其他东西出现问题。当这种情况发生时,你可能需要重新审视你的更改。如果你不确定该怎么做,你应该在问题或 Pull Request 中进行讨论。
如果失败的测试是 代码质量测试,这通常意味着你只需要修复你的代码,使其满足代码质量检查(例如,删除尾部空格)。
然而,偶尔也会发生测试失败但没有错误的情况。在这种情况下,应该更新测试。最常见的情况是测试检查特定表达式,但函数现在返回了不同的、但在数学上等价的表达式。这在使用 doctests 时尤其常见,因为它们不仅检查输出表达式,还检查其打印方式。
如果函数的输出在数学上等价,则可以使用新的输出更新现有的测试。但是,即使这样做,你也应该小心
仔细检查新的输出是否确实相同。手动检查一些东西,例如旧表达式和新表达式的差值是否简化为 0。有时,两个表达式对于某些假设是等价的,但对于所有假设则不然,因此要检查这两个表达式对于所有复数是否真正相同。这在涉及平方根或其他根式的表达式中尤其常见。你可以检查随机数,或者使用
equals()
方法来实现。如果新的输出比旧输出复杂得多,那么即使它们在数学上等价,更新测试可能也不是一个好主意。相反,你可能需要调整更改,使函数仍然返回更简单的结果。
这种情况并不常见,但现有的测试本身可能不正确。如果测试完全错误,则应该直接删除它,并进行更新。
无论如何,在更新现有的测试时,你应该始终在提交消息或 Pull Request 评论中解释这样做的理由。不要在代码注释或文档中解释更改。代码注释和文档只应该参考代码本身。关于更改的讨论应放在提交消息或问题跟踪器中。谈论代码以前如何的代码注释只会变得令人困惑,并且一旦更改完成,实际上就不再相关了。
再次强调,默认情况下不应更改现有的测试。测试的存在是有原因的,更改它们会破坏拥有它们的初衷。这条规则的例外情况是 doctests,如果它们可以改进文档,则允许更改或删除它们,因为 doctests 的主要目的是为用户提供示例。
代码质量检查¶
SymPy 有几个必须通过的代码质量检查。在 Pull Request 上 CI 运行的第一个作业是代码质量检查。如果此作业失败,则不会运行任何其他测试。你的 PR 可能会被审阅者忽略,直到他们被修复。
代码质量检查都非常容易修复。你可以使用以下命令在本地运行检查
./bin/test quality
和
flake8 sympy
第二个命令需要你安装 flake8
。确保你安装了最新版本的 flake8 及其依赖项 pycodestyle
和 pyflakes
。有时,这些软件包的较新版本会添加新的检查,如果你安装了较旧的版本,你将看不到它们的检查。
./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()
生成的相同空格格式化表达式(例如,二元+
和-
周围有空格,*
和**
周围没有空格,逗号后有空格,没有多余的括号等)。避免在测试用例中使用 Float 值。除非测试明确测试函数对浮点输入的结果,否则测试表达式应该使用精确值。
特别是,避免使用整数除法,例如
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 True
、is False
和is None
。不要依赖于真值性,因为很容易忘记 Python 认为None
是假值。# 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% 的覆盖率通常是不可能的。可能有一行防御性代码用于检查是否发生了错误,但只有在出现错误时才会触发。或者可能有一些功能难以测试(例如,某些与 外部依赖项 交互的代码),或者只有在安装了某个可选依赖项时才会触发。但是,如果可以测试代码行,就应该测试。例如,测试文件本身应具有 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