一、doctest概述 — 可执行的文档
doctest是Python标准库中一个轻量级的测试框架,它的核心理念是"可执行的文档"(Executable Documentation)。与传统的将测试代码和文档分离的做法不同,doctest允许开发者将测试用例直接嵌入到文档字符串(docstring)中,使得文档本身就是一个可验证的测试套件。
doctest的设计哲学源于Tim Peters和Guido van Rossum对Python代码可读性的追求。它的工作方式非常直观:扫描文档字符串中所有看起来像是Python交互式解释器会话的文本片段(以>>>开头),然后执行这些代码片段,并将实际输出与文档中预期的输出进行比对。
doctest的核心价值
doctest解决了传统测试和文档编写中常见的几个痛点。其一,文档和测试的统一意味着当代码接口发生变化时,开发者必须同时更新文档中的示例,有效避免了文档过时的问题。其二,低学习成本使得即使是测试新手也能快速上手,无需学习独立的测试框架语法。其三,doctest天然适合作为API的入门教程,因为文档中的示例代码总是经过验证的。
"doctest不是要取代unittest,而是提供了一个互补的、轻量级的选择,特别适合测试简单的函数行为和文档中的示例代码。" — Python官方文档
适用场景
- API文档示例验证:确保文档中给出的使用示例始终正确可用
- 简单函数的回归测试:对于输入输出清晰的纯函数,doctest是最简洁的测试方式
- 教学示例代码:教程和教学材料中的代码示例自动保持正确
- 模块级别的冒烟测试:快速验证模块的基本功能是否正常
局限性
- 不适合复杂的测试场景:涉及大量测试夹具(fixture)、mock对象或复杂状态管理的测试
- 文档字符串中的测试代码可能使文档变得冗长
- 测试输出对空白字符敏感,可能导致假阳性失败
- 对于大型测试套件,维护散落在各处的doctest不如集中管理的unittest方便
二、基本使用 — 文档字符串中写测试
doctest的基本使用方法是在函数的文档字符串中模拟Python交互式解释器的会话。每行测试代码以>>>开头,紧接着是期望的输出。doctest模块会自动识别这些测试片段并执行验证。
第一个doctest示例
下面是一个最简单的doctest示例,它在计算阶乘的函数文档字符串中嵌入了测试用例:
def factorial(n):
"""计算n的阶乘。
>>> factorial(0)
1
>>> factorial(1)
1
>>> factorial(5)
120
>>> factorial(10)
3628800
"""
if n < 0:
raise ValueError("n必须为非负整数")
result = 1
for i in range(2, n + 1):
result *= i
return result
if __name__ == "__main__":
import doctest
doctest.testmod(verbose=True)
当运行这个模块时,doctest.testmod()会扫描模块中所有文档字符串,找到以>>>开头的代码行并执行它们。如果所有输出与预期一致,测试静默通过;如果存在差异,则会报告失败详情。
测试的基本语法规则
doctest的语法规则非常接近Python交互式解释器。每个测试用例由两部分组成:输入部分和输出部分。输入部分以>>>开头,后跟有效的Python表达式或语句。输出部分是紧接着输入部分的下一个连续行块(如果不缩进或不再以>>>开头,则视为输出结束)。
def string_operations():
"""字符串操作示例。
>>> s = "hello world"
>>> s.upper()
'HELLO WORLD'
>>> s.split()
['hello', 'world']
>>> s.replace("world", "python")
'hello python'
"""
pass
值得注意的是,doctest比较输出时非常严格。它期望输出完全匹配,包括空白字符、引号类型、标点符号等。字符串的表示形式遵循repr()的输出规则,因此在大多数情况下字符串字面量应该使用单引号包裹。
多行输入与续行
当测试代码需要跨越多行时,可以使用续行符...来表示输入继续。需要注意的是,这里的...是doctest的续行提示符,而不是Python代码中的省略号:
def list_comprehension_demo():
"""列表推导式演示。
>>> numbers = [1, 2, 3, 4, 5]
>>> [x ** 2 for x in numbers
... if x % 2 == 0]
[4, 16]
"""
pass
在doctest中,以...开头的行被视为上一行的继续输入,而不是新的测试用例。这允许测试多行的表达式、循环体或复合语句。
空白输出的处理
当测试函数的输出为空字符串时,需要特别注意doctest的空白处理规则。输出部分中的空行表示输出的结束,因此空字符串输出需要用空行来表示。如果期望输出为空但实际有输出,或者相反,doctest会报告不匹配:
def no_output():
"""无输出函数。
>>> no_output()
"""
print()
是一个特殊的doctest指令,用于显式标记期望的空白行。当函数的输出中包含空行时,必须在预期输出中使用来匹配这些空行。这避免了空行与测试用例分隔符之间的歧义。
三、运行测试 — testmod/testfile/命令行
doctest提供了多种运行测试的方式,以适应不同的使用场景和开发工作流。开发者可以根据项目需求选择最合适的方式。
1. doctest.testmod() — 模块级测试
testmod()是doctest最常用的函数,它会扫描指定模块(默认为__main__)的所有文档字符串,找到并执行其中的测试用例。
import doctest
def add(a, b):
"""两数相加。
>>> add(2, 3)
5
>>> add(-1, 1)
0
>>> add(0.1, 0.2) # 浮点数精度问题
0.30000000000000004
"""
return a + b
if __name__ == "__main__":
# verbose=True 会显示所有测试的详细信息
# verbose=False(默认)只在测试失败时输出
results = doctest.testmod(verbose=True)
print(f"测试结果: 尝试{results.attempted}个, 失败{results.failed}个")
testmod()返回一个namedtuple,包含attempted(尝试的测试数)和failed(失败的测试数)两个字段。在模块的__main__块中调用testmod()是最常见的做法,这样当模块作为脚本直接运行时自动执行测试,而作为模块导入时则不会触发测试。
2. doctest.testfile() — 外部文件测试
testfile()允许将测试用例存储在独立的文本文件中,而不是嵌入在文档字符串中。这对于测试用例较多或文档字符串过长的情况特别有用。
假设有一个名为"tests.txt"的文件:
测试模块 string_utils
====================
>>> from string_utils import reverse, is_palindrome
>>> reverse("hello")
'olleh'
>>> reverse("")
''
>>> is_palindrome("racecar")
True
>>> is_palindrome("hello")
False
>>> is_palindrome("A man a plan a canal Panama")
False
在测试运行器中调用testfile():
import doctest
if __name__ == "__main__":
# 运行外部文件中的测试
# module_relative=False 表示使用绝对路径
results = doctest.testfile("tests.txt",
module_relative=False,
verbose=True)
print(f"测试结果: 尝试{results.attempted}个, 失败{results.failed}个")
testfile()默认在包含测试文件的目录中执行测试,并假设文件中的import语句可以直接使用。通过optionflags参数可以控制测试行为,通过globs参数可以注入全局变量。
3. python -m doctest — 命令行运行
doctest支持通过Python模块的-m开关从命令行直接运行,无需在代码中显式调用testmod():
# 直接测试一个Python模块中的所有docstring
python -m doctest my_module.py -v
# 测试独立文本文件中的测试用例
python -m doctest tests.txt -v
-v(或--verbose)选项启用详细输出模式,显示所有测试的执行情况和结果。如果不加-v,则只在测试失败时输出错误信息。这是最快速的测试方式,特别适合在持续集成(CI)流水线中快速验证文档示例的正确性。
4. 选择性测试
在实际项目中,可能需要只测试模块中的部分函数。doctest允许通过extraglobs和exclude_empty参数来控制测试范围:
import doctest
def func_a():
"""函数A。
>>> func_a()
'A'
"""
return "A"
def func_b():
"""函数B。
>>> func_b()
'B'
"""
return "B"
if __name__ == "__main__":
doctest.testmod(extraglobs={"func_a": func_a})
# 只测试func_a的docstring
四、异常与空白 — Traceback测试
doctest在处理异常和空白字符方面提供了灵活的机制。正确理解这些机制对于编写可靠、可维护的doctest至关重要。
测试异常抛出
doctest可以直接测试函数在特定条件下是否正确抛出异常。测试异常的语法是在>>>行写出会引发异常的表达式,然后在接下来的行中写出预期的Traceback和异常信息。
def divide(a, b):
"""除法函数,演示异常测试。
>>> divide(10, 2)
5.0
>>> divide(10, 0)
Traceback (most recent call last):
...
ZeroDivisionError: division by zero
>>> divide("10", 2)
Traceback (most recent call last):
...
TypeError: unsupported operand type(s) for /: 'str' and 'int'
"""
return a / b
doctest在异常测试方面非常灵活。它只检查Traceback的最后一行(即异常类型和异常信息),Traceback中间的行可以用...省略。这是因为Traceback的具体行号、调用栈等信息在不同运行环境下是有差异的,doctest明智地选择忽略这些差异部分。
关键点:doctest对Traceback的检查策略是:忽略Traceback的中间细节(从"Traceback (most recent call last):"到异常类型之间),只验证最后一行异常类型和异常信息字符串。这种设计让异常测试在不同环境、不同Python版本下都能保持稳定。
ELLIPSIS选项 — 灵活匹配输出
ELLIPSIS选项(doctest.ELLIPSIS)允许在预期输出中使用...作为通配符,匹配任意长度的任意字符序列(包括跨行匹配)。这在处理包含动态内容(如对象ID、时间戳、文件路径等)的输出时非常有用。
import doctest
def get_object_info():
"""返回对象信息字符串。
>>> import sys
>>> sys.version # doctest: +ELLIPSIS
'3.1...'
"""
import sys
return sys.version
if __name__ == "__main__":
doctest.testmod(optionflags=doctest.ELLIPSIS)
ELLIPSIS选项有两种启用方式:一种是通过optionflags参数全局启用;另一种是通过doctest指令在单个测试用例上启用。后者的语法是在测试行末尾添加# doctest: +FLAG注释,这种细粒度的控制更为灵活:
def get_id(obj):
"""获取对象ID。
>>> get_id([1, 2, 3]) # doctest: +ELLIPSIS
'对象ID: 0x...'
"""
return f"对象ID: {hex(id(obj))}"
NORMALIZE_WHITESPACE选项 — 空白规范化
NORMALIZE_WHITESPACE选项(doctest.NORMALIZE_WHITESPACE)在比较输出时忽略空白字符的差异。这意味着预期输出和实际输出在空白字符(空格、制表符、换行)方面不必严格匹配,只要单词和标点的顺序一致即可。
def format_table():
"""格式化表格。
>>> format_table() # doctest: +NORMALIZE_WHITESPACE
姓名 年龄 城市
Alice 30 北京
Bob 25 上海
"""
return ("姓名 年龄 城市\n"
"Alice 30 北京\n"
"Bob 25 上海")
没有NORMALIZE_WHITESPACE时,doctest要求精确的空白匹配,这在处理格式化输出时可能导致测试过于脆弱。启用该选项后,连续的空格序列被视为等同于单个空格,开头和结尾的空白也会被忽略。
SKIP选项 — 跳过特定测试
SKIP选项(doctest.SKIP)让doctest跳过指定的测试用例。这在文档中保留示例但同时不希望它被测试验证的场景下非常有用:
def experimental_api():
"""实验性API(待定)。
>>> experimental_api() # doctest: +SKIP
'这个测试暂时跳过'
"""
return "实现尚未完成"
实际输出的空白行处理
当实际输出中包含空行时,需要在预期输出中使用<BLANKLINE>来显式标记空行。doctest将<BLANKLINE>视为一个空行的占位符,并在比较输出时将空行与<BLANKLINE>进行匹配。
def multi_line_output():
"""多行输出示例。
>>> multi_line_output()
第一行
第三行
"""
return "第一行\n\n第三行"
如果不使用<BLANKLINE>,doctest无法区分输出中的空行和测试用例之间的分隔,会导致测试失败。这是doctest新手最常遇到的陷阱之一。
五、高级特性 — 选项标记与unittest集成
doctest提供了一系列高级特性,帮助开发者应对各种复杂的测试场景。将doctest与unittest或pytest集成,可以构建更完整、更强大的测试体系。
选项标记(Option Flags)详解
doctest的选项标记是控制测试行为的开关,可以通过全局方式(传递给testmod()或testfile()的optionflags参数)或局部方式(在测试行末尾添加# doctest: +FLAG注释)使用。
以下是常用选项标记的汇总:
| 选项标记 | 说明 | 适用场景 |
| ELLIPSIS | 允许使用...匹配任意字符序列 | 对象ID、版本号、时间戳等动态内容 |
| NORMALIZE_WHITESPACE | 忽略空白字符差异 | 格式化输出、多行字符串 |
| IGNORE_EXCEPTION_DETAIL | 忽略异常描述信息的具体细节 | 跨Python版本兼容的异常测试 |
| SKIP | 跳过该测试用例 | 待完善功能、文档占位示例 |
| DONT_ACCEPT_TRUE_FOR_1 | 不将1视为True | 需要严格区分布尔值和整数 |
| DONT_ACCEPT_BLANKLINE_MARKER | 不使用<BLANKLINE>标记 | 自定义空行标记 |
| REPORT_UDIFF | 使用统一差异格式报告失败 | 需要详细差异信息的调试场景 |
| REPORT_CDIFF | 使用上下文差异格式报告失败 | 分析复杂输出的差异 |
| REPORT_NDIFF | 使用行差异格式报告失败 | 逐行比较输出差异 |
| FAIL_FAST | 在第一次失败后立即停止 | 快速定位问题,CI环境中的高效测试 |
IGNORE_EXCEPTION_DETAIL — 跨版本兼容的异常测试
Python不同版本之间,异常信息的格式可能存在细微差异。IGNORE_EXCEPTION_DETAIL选项在测试异常时只检查异常类型,忽略异常描述信息中的细节部分。
import doctest
def parse_number(s):
"""解析数字字符串。
>>> parse_number("123") # doctest: +IGNORE_EXCEPTION_DETAIL
123
>>> parse_number("abc") # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
ValueError
"""
return int(s)
if __name__ == "__main__":
doctest.testmod(optionflags=doctest.IGNORE_EXCEPTION_DETAIL)
该选项在处理跨Python版本兼容性时非常关键。例如,Python 3.11和Python 3.12中某些异常的文本表述可能不同,启用此选项后,只要异常类型匹配,测试就通过。
DONT_ACCEPT_TRUE_FOR_1 — 严格类型区分
默认情况下,doctest将输出中的True和1视为等价,False和0也视为等价。DONT_ACCEPT_TRUE_FOR_1选项禁用这一行为,要求输出必须精确匹配:
import doctest
def check_truthy():
"""检查真值。
>>> check_truthy() # doctest: +DONT_ACCEPT_TRUE_FOR_1
True
"""
return 1 # 整数1,但预期是True,测试会失败
if __name__ == "__main__":
doctest.testmod(optionflags=doctest.DONT_ACCEPT_TRUE_FOR_1)
doctest.DocFileSuite — 与unittest集成
将doctest集成到unittest测试框架中,是最佳实践之一。doctest.DocFileSuite会从文本文件中加载测试,并将其包装为unittest.TestSuite,使得doctest可以无缝融入已有的unittest测试体系。
import unittest
import doctest
def load_tests(loader, tests, ignore):
"""unittest测试加载器,自动发现并加载doctest。"""
# 从tests.txt文件加载测试
tests.addTests(doctest.DocFileSuite(
"tests.txt",
module_relative=True,
optionflags=doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE,
))
# 扫描当前模块中的文档字符串
tests.addTests(doctest.DocTestSuite(
optionflags=doctest.ELLIPSIS,
))
return tests
def add(a, b):
"""两数相加。
>>> add(1, 2)
3
>>> add(10, 20)
30
"""
return a + b
if __name__ == "__main__":
unittest.main()
DocTestSuite的作用与DocFileSuite类似,但它从Python模块的文档字符串中提取测试用例。通过实现load_tests函数(unittest的钩子函数),可以让测试运行器自动发现并执行doctest测试。
doctest与pytest集成
pytest框架通过pytest内置的doctest插件提供了对doctest的原生支持。只需传入--doctest-modules选项,pytest就会自动发现并运行项目中所有模块的doctest:
# 运行项目中所有模块的doctest
pytest --doctest-modules
# 同时显示详细输出
pytest --doctest-modules -v
# 指定doctest选项标记
pytest --doctest-modules --doctest-glob='*.rst'
# 允许doctest使用ELLIPSIS
pytest --doctest-modules \
--doctest-option=ELLIPSIS \
--doctest-option=NORMALIZE_WHITESPACE
pytest的doctest集成还支持在conftest.py中配置doctest选项,实现全局统一的测试策略。通过pytest.ini或pyproject.toml也可以持久化doctest配置:
# pytest.ini 示例
[pytest]
doctest_optionflags = ELLIPSIS NORMALIZE_WHITESPACE
doctest_glob = *.rst *.txt
testpaths = src tests
组合多个选项标记
在实际项目中,通常需要组合多个选项标记来应对复杂的测试场景。选项标记通过按位或运算符(|)组合:
import doctest
if __name__ == "__main__":
doctest.testmod(
optionflags=(
doctest.ELLIPSIS |
doctest.NORMALIZE_WHITESPACE |
doctest.IGNORE_EXCEPTION_DETAIL |
doctest.REPORT_UDIFF |
doctest.FAIL_FAST
),
verbose=True
)
在单个测试行中也可以组合多个标记:
def complex_function():
"""复杂函数。
>>> complex_function() # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE
结果: ...
"""
return "结果: 成功"
六、核心总结
doctest模块核心要点速记:
1. 可执行的文档:将测试嵌入文档字符串,文档即测试,测试即文档,天然统一。
2. 三种运行方式:testmod()扫描模块文档、testfile()加载外部文件、python -m doctest命令式运行。
3. Traceback测试:使用...省略中间行,只验证异常类型和信息,灵活应对不同环境。
4. 三大关键选项:ELLIPSIS匹配动态内容、NORMALIZE_WHITESPACE忽略空白差异、IGNORE_EXCEPTION_DETAIL兼容各版本异常。
5. 集成策略:通过DocFileSuite/DocTestSuite与unittest集成,或通过pytest --doctest-modules一键集成。
6. 最佳实践:doctest适合简单函数示例验证;复杂测试交给unittest/pytest;两者互补使用效果最佳。
doctest使用建议
| 场景 | 推荐工具 | 理由 |
| API文档示例验证 | doctest | 示例代码即测试,文档永不落伍 |
| 简单纯函数测试 | doctest | 零成本上手,验证输入输出关系 |
| 复杂业务逻辑测试 | unittest/pytest | 需要夹具、mock、参数化等机制 |
| 大型项目测试体系 | doctest + pytest | doctest验证文档示例,pytest覆盖完整逻辑 |
| 教学示例代码 | doctest | 读者可亲自运行,确保教学代码正确 |
| 第三方库接口示例 | doctest | 降低使用门槛,示例本身就是测试 |
常见陷阱与规避
- 空白字符敏感:养成在需要的地方使用NORMALIZE_WHITESPACE的习惯
- 字典无序输出:Python 3.7+中字典保持插入顺序,但仍推荐显式控制顺序或使用ELLIPSIS
- 浮点数精度:使用round()或格式化字符串来控制浮点数输出精度
- <BLANKLINE>遗忘:输出中有空行时记得使用<BLANKLINE>标记
- 对象地址动态变化:在测试涉及id()、repr()包含地址的输出时使用ELLIPSIS
检查清单
- 所有文档字符串示例是否可被doctest正确识别?
- 异常测试中Traceback的...是否正确使用?
- 是否根据输出特性启用了合适的选项标记?
- 是否有明确的测试运行入口(__main__块中的testmod调用)?
- doctest是否已集成到项目的自动化测试流程中?