Lisa Tagliaferri 2021-02-06
文档和测试是每个高效软件开发流程的核心组成部分。确保代码经过充分的文档说明和测试,不仅能保证程序按预期运行,还能促进程序员之间的协作以及用户的采用。程序员可以先编写文档,再编写测试,最后才编写代码。遵循这样的流程,可以确保所编写的函数(例如)经过深思熟虑,并能处理各种可能的边界情况。
Python 标准库自带一个名为 doctest 的测试框架模块。doctest 模块会以编程方式搜索 Python 代码中的注释内容,查找看起来像交互式 Python 会话的文本片段。然后,该模块会执行这些会话,以确认 doctest 所引用的代码是否按预期运行。
此外,doctest 还能为我们的代码生成文档,提供输入-输出示例。根据你编写 doctest 的方式,它可以更接近于“文学化测试”(literate testing)或“可执行文档”(executable documentation),正如 Python 标准库文档所述。
前提条件
你应该已在计算机或服务器上安装了 Python 3 并配置好了编程环境。如果你尚未设置编程环境,可以参考适用于你操作系统的本地或服务器编程环境安装与设置指南(如 Ubuntu、CentOS、Debian 等)。
Doctest 结构
Python 的 doctest 编写方式类似于注释,使用连续三个双引号 """ 包裹,位于 doctest 的开头和结尾。
有时,doctest 仅包含函数调用示例及其预期输出,但更推荐同时加入对函数用途的说明性注释。添加注释有助于程序员明确目标,也能让未来的代码阅读者更好地理解代码——而这个未来的程序员很可能就是你自己。
提示:若要跟随本文中的示例代码操作,请在本地系统上打开 Python 交互式 shell,运行
python3命令。然后你可以在>>>提示符后复制、粘贴或编辑示例代码。
以下是一个数学函数 add(a, b)(用于将两个数字相加)的 doctest 示例:
"""
Given two integers, return the sum.
>>> add(2, 3)
5
"""
在此示例中,我们有一行功能说明,以及一个使用两个整数作为输入的 add() 函数调用示例。如果将来你希望该函数能够支持两个以上整数相加,则需要相应地修改 doctest 以匹配函数的实际输入。
目前,这个 doctest 对人类读者来说已经非常清晰。你可以进一步优化这个文档字符串(docstring),通过添加机器可读的参数说明和返回值描述,明确指出每个传入和传出变量的含义。
下面,我们为传入的两个参数和返回值添加 docstring 注解,并注明它们的数据类型——参数 a、参数 b 和返回值在此例中均为整数:
"""
Given two integers, return the sum.
:param a: int
:param b: int
:return: int
>>> add(2, 3)
5
"""
现在,这个 doctest 已准备好被整合进函数并进行测试。
将 Doctest 整合进函数
Doctest 应放置在函数定义(def 语句)之后、函数主体代码之前。按照 Python 的缩进规范,它应与函数体保持一致的缩进。
以下短函数展示了如何整合 doctest:
def add(a, b):
"""
Given two integers, return the sum.
:param a: int
:param b: int
:return: int
>>> add(2, 3)
5
"""
return a + b
在我们的简短示例中,程序只包含这一个函数,因此还需要导入 doctest 模块,并添加调用语句来运行测试。
我们需要在函数前后分别添加以下代码:
import doctest
...
doctest.testmod()
现在,我们先在 Python 交互式 shell 中测试,而不是立即保存为程序文件。你可以在终端(包括 IDE 终端)中运行 python3 命令(或在虚拟环境中使用 python)来启动 Python 3 shell:
python3
按下回车后,你会看到类似以下的输出:
Type "help", "copyright", "credits" or "license" for more information.
>>>
你可以在 >>> 提示符后开始输入代码。
以下是完整的示例代码,包含 add() 函数、doctest、文档字符串以及调用 doctest 的语句。你可以将其粘贴到 Python 解释器中尝试:
import doctest
def add(a, b):
"""
Given two integers, return the sum.
:param a: int
:param b: int
:return: int
>>> add(2, 3)
5
"""
return a + b
doctest.testmod()
运行代码后,你会收到如下输出:
TestResults(failed=0, attempted=1)
这意味着程序运行符合预期!
如果你将 return a + b 修改为 return a * b(即让函数执行乘法而非加法),你将收到失败通知:
**********************************************************************
File "__main__", line 9, in __main__.add
Failed example:
add(2, 3)
Expected:
5
Got:
6
**********************************************************************
1 items had failures:
1 of 1 in __main__.add
*** Test Failed *** 1 failures.
TestResults(failed=1, attempted=1)
从上述输出可以看出,doctest 模块非常有用——它完整描述了当 a 和 b 被相乘而非相加时发生的情况(本例中返回了乘积 6)。
你也可以尝试添加更多测试示例。例如,再增加一个 a 和 b 都为 0 的例子,并将函数改回使用加法运算符 +:
import doctest
def add(a, b):
"""
Given two integers, return the sum.
:param a: int
:param b: int
:return: int
>>> add(2, 3)
5
>>> add(0, 0)
0
"""
return a + b
doctest.testmod()
运行后,你会收到如下反馈:
TestResults(failed=0, attempted=2)
这表明 doctest 尝试了两个测试(add(2, 3) 和 add(0, 0)),且全部通过。
但如果我们再次将函数改为使用乘法运算符 *,就会发现边界情况的重要性:add(0, 0) 在加法和乘法下都会返回 0。
import doctest
def add(a, b):
"""
Given two integers, return the sum.
:param a: int
:param b: int
:return: int
>>> add(2, 3)
5
>>> add(0, 0)
0
"""
return a * b
doctest.testmod()
此时输出为:
**********************************************************************
File "__main__", line 9, in __main__.add
Failed example:
add(2, 3)
Expected:
5
Got:
6
**********************************************************************
1 items had failures:
1 of 2 in __main__.add
*** Test Failed *** 1 failures.
TestResults(failed=1, attempted=2)
修改程序后,只有一个示例失败,但 doctest 依然完整地描述了问题。如果我们最初只写了 add(0, 0) 而没有 add(2, 3),就可能无法察觉程序微小改动带来的潜在错误。
在程序文件中使用 Doctest
到目前为止,我们一直在 Python 交互式终端中演示。现在,我们将在一个程序文件中使用 doctest,实现一个统计单个单词中元音字母数量的功能。
在程序文件中,我们通常在文件底部的 if __name__ == "__main__": 子句中导入并调用 doctest 模块。
首先,在文本编辑器中创建一个新文件 counting_vowels.py。你可以在命令行中使用 nano:
nano counting_vowels.py
先定义函数 count_vowels,并传入参数 word:
# counting_vowels.py
def count_vowels(word):
在编写函数主体之前,先在 doctest 中说明函数的预期行为:
def count_vowels(word):
"""
Given a single word, return the total number of vowels in that single word.
"""
说明很清晰。接下来,补充参数 word 和返回值的数据类型:前者是字符串(str),后者是整数(int)。
def count_vowels(word):
"""
Given a single word, return the total number of vowels in that single word.
:param word: str
:return: int
"""
然后,添加一些测试示例。选择一个包含元音的单词,比如秘鲁城市 “Cusco”。在英语中,元音通常指 a、e、i、o、u,因此 “Cusco” 中的 u 和 o 共计 2 个元音。
将测试用例加入文档字符串:
def count_vowels(word):
"""
Given a single word, return the total number of vowels in that single word.
:param word: str
:return: int
>>> count_vowels('Cusco')
2
"""
建议提供多个示例。再选一个元音更多的词,比如菲律宾城市 “Manila”:
def count_vowels(word):
"""
Given a single word, return the total number of vowels in that single word.
:param word: str
:return: int
>>> count_vowels('Cusco')
2
>>> count_vowels('Manila')
3
"""
现在可以编写函数主体了。
首先初始化一个变量 total_vowels 用于记录元音数量。接着使用 for 循环遍历单词中的每个字母,并通过条件判断检查该字母是否为元音。在循环中累加元音数量,最后返回总数。不包含 doctest 的函数主体如下:
def count_vowels(word):
total_vowels = 0
for letter in word:
if letter in 'aeiou':
total_vowels += 1
return total_vowels
(如果你需要更多指导,请参考我们的《How To Code in Python》书籍或配套系列教程。)
接下来,在程序底部添加主入口和 doctest 调用:
if __name__ == "__main__":
import doctest
doctest.testmod()
至此,完整程序如下:
# counting_vowels.py
def count_vowels(word):
"""
Given a single word, return the total number of vowels in that single word.
:param word: str
:return: int
>>> count_vowels('Cusco')
2
>>> count_vowels('Manila')
3
"""
total_vowels = 0
for letter in word:
if letter in 'aeiou':
total_vowels += 1
return total_vowels
if __name__ == "__main__":
import doctest
doctest.testmod()
使用以下命令运行程序(根据你的环境,可能是 python 或 python3):
python counting_vowels.py
如果程序完全一致,所有测试都会通过,且不会有任何输出——这表示测试成功。这种“静默成功”特性在将程序用于其他目的时非常有用。
如果你专门为了测试而运行,可以加上 -v(verbose)标志:
python counting_vowels.py -v
你将看到如下输出:
Trying:
count_vowels('Cusco')
Expecting:
2
ok
Trying:
count_vowels('Manila')
Expecting:
3
ok
1 items had no tests: __main__
1 items passed all tests:
2 tests in __main__.count_vowels
2 tests in 2 items.
2 passed and 0 failed.
Test passed.
太棒了!测试已通过。不过,我们的代码可能仍未覆盖所有边界情况。接下来,我们将学习如何利用 doctest 来进一步强化代码的健壮性。