文档测试
文档测试(doctest)会寻找文档中形似 Python 交互式解释器的示例,执行 >>> 后面的代码,再把真实输出与示例中写出的期望输出进行比较。这样,一段示例既能教读者如何使用代码,也能自动检查文档有没有过期。
本文使用 Python 3.10 及以上版本。学完后,你应该能够:
- 在模块、函数和类的文档字符串中编写 doctest;
- 理解表达式结果、
print()输出、无输出和异常的匹配规则; - 从命令行、Python 代码或
unittest中运行文档测试; - 使用
ELLIPSIS、NORMALIZE_WHITESPACE等选项处理合理变化; - 判断何时适合使用 doctest,何时应该改用单元测试。
第一个文档测试
创建一个包含交互式示例的模块:
def add(left: int, right: int) -> int:
"""返回两个整数之和。
>>> add(2, 3)
5
>>> add(-1, 1)
0
"""
return left + right
在模块所在目录运行:
python -m doctest calculator.py
全部通过时默认没有输出。加上 -v 可以查看发现和执行了哪些示例:
python -m doctest -v calculator.py
如果把实现错误地改成 return left - right,测试会报告类似结果:
Failed example:
add(2, 3)
Expected:
5
Got:
-1
失败报告同时指出执行语句、期望输出和实际输出,适合快速发现示例与代码已经不一致。
doctest 如何识别示例
doctest 模仿标准交互式解释器:
>>>是一条新语句的提示符;...是多行语句的续行提示符;- 提示符后是要执行的 Python 代码;
- 紧随其后的文本是期望输出;
- 没有期望输出,表示这段代码应该不输出任何内容。
例如:
def double_all(numbers: list[int]) -> list[int]:
"""返回所有元素的两倍。
>>> double_all([1, 2, 3])
[2, 4, 6]
>>> double_all([])
[]
"""
return [number * 2 for number in numbers]
测试代码只存在于字符串里,编辑器不一定会像检查普通 Python 文件那样完整检查它。因此,必须实际运行 doctest。
多行代码
复合语句使用 ... 继续输入,并用空的续行结束代码块:
def positive_numbers(numbers: list[int]) -> list[int]:
"""只保留正数。
>>> values = []
>>> for number in [-2, 0, 3, 5]:
... if number > 0:
... values.append(number)
>>> values
[3, 5]
"""
return [number for number in numbers if number > 0]
如果表达式需要跨行,使用括号通常更容易阅读:
>>> total = sum(
... [10, 20, 30]
... )
>>> total
60
提示符必须与交互式会话一致,不能把普通代码缩进误写成输入提示符。
期望输出的匹配规则
doctest 默认按文本比较输出,空格、引号、顺序和数值表示都可能影响结果。
表达式结果与 print() 输出
交互式解释器会显示非 None 表达式结果的 repr():
>>> "hello\nworld"
'hello\nworld'
print() 则把换行真正输出:
>>> print("hello\nworld")
hello
world
文档中必须写出与所选方式对应的结果。直接计算返回 None 的表达式不会产生回显:
>>> result = print("完成")
完成
>>> result is None
True
没有输出时不要写 None
赋值语句没有交互式输出:
>>> name = "小林"
这里的空白表示“期望没有输出”,不是遗漏。除非代码真的打印或回显 None,否则不要额外写一行 None。
空白默认是严格的
默认情况下,期望输出中的空白需要匹配。对于为了排版而换行的长输出,可以只为该示例启用 NORMALIZE_WHITESPACE:
>>> sorted([3, 1, 2]) # doctest: +NORMALIZE_WHITESPACE
[1,
2, 3]
启用后,连续空白的差异会被忽略。不要用它掩盖缩进本身就是结果一部分的错误,例如代码生成器或格式化器的输出。
不稳定顺序会造成失败
下面的集合显示顺序不应作为稳定约定:
# 不推荐:输出顺序可能变化
# >>> {"python", "go", "dart"}
# {'python', 'go', 'dart'}
先转换成稳定结果:
>>> sorted({"python", "go", "dart"})
['dart', 'go', 'python']
同理,涉及字典来源、文件遍历、并发任务或数据库返回结果时,如果顺序不属于接口约定,应先排序或只验证稳定属性。
浮点数需要主动稳定
doctest 比较的是显示文本,不会自动进行近似比较:
>>> round(0.1 + 0.2, 10)
0.3
也可以让示例验证误差范围:
>>> abs((0.1 + 0.2) - 0.3) < 1e-12
True
若精度规则很重要,使用单元测试的 assertAlmostEqual() 往往能更清楚地表达允许误差。
测试异常
文档可以演示调用失败时抛出的异常:
def reciprocal(number: float) -> float:
"""返回倒数。
>>> reciprocal(4)
0.25
>>> reciprocal(0)
Traceback (most recent call last):
...
ZeroDivisionError: float division by zero
"""
return 1.0 / number
异常示例至少要写:
Traceback (most recent call last):标题;- 一段缩进的 traceback 内容,常用
...作为占位; - 最后一行异常类型和消息。
doctest 对 traceback 中间的调用栈细节采用特殊匹配规则,不需要复制会随文件路径和行号变化的完整内容。但异常类型和消息默认仍需匹配。不同 Python 版本可能调整内置异常消息;跨版本项目如果只关心异常类型,通常更适合使用单元测试明确断言。
在哪些位置编写 doctest
模块文档字符串
适合展示多个 API 如何配合:
"""温度转换工具。
>>> celsius_to_fahrenheit(0)
32.0
>>> fahrenheit_to_celsius(212)
100.0
"""
def celsius_to_fahrenheit(value: float) -> float:
return value * 9 / 5 + 32
def fahrenheit_to_celsius(value: float) -> float:
return (value - 32) * 5 / 9
函数、类和方法文档字符串
适合紧贴某个公开接口展示常见用法:
class Counter:
"""一个简单计数器。
>>> counter = Counter()
>>> counter.increment()
>>> counter.increment()
>>> counter.value
2
"""
def __init__(self):
self.value = 0
def increment(self) -> None:
self.value += 1
模块的 __test__ 字典
__test__ 可以保存不适合公开为正式文档字符串、但仍希望 doctest 发现的示例:
__test__ = {
"empty_cart": """
>>> calculate_total([])
0
""",
}
字典的值通常是字符串、函数、类或方法。它适合补充示例,但大量复杂测试放在这里会降低可读性,此时应改用独立测试文件。
独立文本文件
doctest 也能执行普通文本文件中的交互式示例:
价格工具的基本用法:
>>> from price import final_price
>>> final_price(100, 0.2)
80.0
运行:
python -m doctest guide.txt
独立文件适合教程式说明。它的命名不必以 .txt 结尾,只要传给正确的运行入口;但项目应采用一致扩展名,并确保渲染工具与 doctest 都能正确处理提示符。
三种运行方式
从命令行运行
可以一次检查多个文件:
python -m doctest -v calculator.py guide.txt
命令会执行文件,因此只能对可信代码运行。被测模块导入时也不应执行网络请求、删除文件等意外副作用。
在模块中调用 testmod()
可以让模块在被直接执行时测试自己的文档字符串:
if __name__ == "__main__":
import doctest
doctest.testmod()
然后运行:
python calculator.py
也可以查看返回的失败数和执行数:
import calculator
import doctest
result = doctest.testmod(calculator, verbose=True)
print(result.failed, result.attempted)
testmod() 默认检查传入模块中可发现的文档字符串。没有传入模块时,它检查调用它的当前模块。
使用 testfile() 测试文本文件
import doctest
doctest.testfile("guide.txt", module_relative=False)
module_relative 会影响相对路径的解释方式:默认相对于调用模块查找;设为 False 时,相对当前工作目录查找。为避免不同启动目录造成困惑,实际项目可以用 pathlib.Path 明确构造路径。
示例之间是否共享状态
同一个文档字符串中的示例通常在同一份执行命名空间中依次运行,因此后面的示例可以使用前面创建的变量:
>>> cart = []
>>> cart.append("book")
>>> len(cart)
1
不同文档字符串会获得独立的浅拷贝命名空间,不应依赖另一个文档字符串先运行。即使在同一文档字符串中,也不要建立过长的状态链;前面一个示例失败会让后续失败变得难以理解。
需要为所有示例提供辅助对象时,testmod() 和 testfile() 接受 globs 或 extraglobs:
doctest.testfile(
"guide.txt",
module_relative=False,
globs={"sample_prices": [10, 20, 30]},
)
如果示例会修改列表、字典等可变对象,要注意浅拷贝仍可能共享其中的对象。更稳妥的做法是让每个重要场景自己创建数据。
使用选项指令
在输入行末添加 # doctest: 可以只改变当前示例的匹配方式。多个选项以逗号分隔,每个名称前必须有 + 或 -:
>>> generate_identifier() # doctest: +ELLIPSIS
'item-...'
ELLIPSIS
启用后,期望输出中的 ... 可以匹配任意文本:
>>> object() # doctest: +ELLIPSIS
<object object at 0x...>
它适合忽略内存地址、随机标识中不重要的部分等。范围过大的省略号可能隐藏真正错误,应尽量在前后保留稳定且有意义的文本。
NORMALIZE_WHITESPACE
把连续空白视为等价,适合不关心自动换行差异的长结果:
>>> print_words() # doctest: +NORMALIZE_WHITESPACE
alpha beta gamma
SKIP
跳过无法在当前测试环境执行的示例:
>>> open_browser() # doctest: +SKIP
跳过的示例不会验证任何行为,必须说明为什么不能运行,并尽量把核心逻辑拆成可测试部分。不要把 SKIP 当作修复失败的快捷方式。
其他常用选项
| 选项 | 作用 |
|---|---|
IGNORE_EXCEPTION_DETAIL | 比较异常时忽略冒号后的消息等细节 |
DONT_ACCEPT_TRUE_FOR_1 | 不再把 True 与 1、False 与 0 视为兼容 |
REPORT_NDIFF | 失败时显示逐字符差异 |
REPORT_UDIFF | 失败时显示统一格式差异 |
FAIL_FAST | 第一次失败后停止 |
选项也可以通过 optionflags 全局传给运行函数:
import doctest
doctest.testmod(
optionflags=doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE,
)
优先只为确有需要的示例开启宽松规则。全局放宽匹配会让所有文档示例都更难发现细微错误。
与 unittest 集成
可以把模块文档字符串转换成 unittest 测试套件:
import doctest
import unittest
import calculator
def load_tests(loader, tests, ignore):
tests.addTests(doctest.DocTestSuite(calculator))
return tests
if __name__ == "__main__":
unittest.main()
运行:
python -m unittest test_documentation.py
独立文本文件使用 DocFileSuite():
def load_tests(loader, tests, ignore):
tests.addTests(
doctest.DocFileSuite(
"guide.txt",
module_relative=False,
)
)
return tests
这样文档测试可以和普通单元测试通过相同命令运行,并进入持续集成流程。load_tests 是 unittest 支持的测试加载协议,函数名和参数形式不能随意更改。
为示例提供准备和清理逻辑
DocTestSuite() 和 DocFileSuite() 支持 setUp 与 tearDown 回调。回调接收当前 DocTest 对象:
from tempfile import TemporaryDirectory
def set_up(test):
test.globs["temp_dir"] = TemporaryDirectory()
def tear_down(test):
test.globs["temp_dir"].cleanup()
def load_tests(loader, tests, ignore):
tests.addTests(
doctest.DocFileSuite(
"file-guide.txt",
module_relative=False,
setUp=set_up,
tearDown=tear_down,
)
)
return tests
然后文档示例可以从 temp_dir.name 下创建文件。准备逻辑应尽量小,并确保清理不会依赖示例成功执行。
适合使用 doctest 的场景
doctest 特别适合:
- 纯函数的短小输入输出示例;
- 教程中的交互式探索过程;
- 文本转换、数值计算和容器操作;
- 验证公开 API 文档没有因重构而过期;
- 能用稳定文本清楚表达的边界行为。
例如:
def slugify(text: str) -> str:
"""把简单标题转换为 slug。
>>> slugify("Hello Python")
'hello-python'
>>> slugify(" Learn Fast ")
'learn-fast'
"""
return "-".join(text.lower().split())
示例短、答案明确,同时具备教学价值。
不适合只用 doctest 的场景
以下情况通常更适合 unittest,必要时再配少量文档示例:
- 需要复杂夹具和大量准备步骤;
- 要验证对象身份、浮点误差或复杂结构的局部属性;
- 输出包含时间、随机数、地址或不稳定顺序;
- 要大量 Mock 网络、数据库等依赖;
- 要检查多种异常属性或内部状态;
- 用例很多,采用数据驱动测试更清楚;
- 代码有文件、网络、支付等重要副作用。
doctest 的首要读者仍然是人。为了测试覆盖率而塞入大量难读的交互记录,会同时降低文档和测试质量。
常见问题与排查方法
全部通过,但其实没有发现测试
doctest 全部通过时默认静默,容易与“没有找到任何示例”混淆。首次接入时使用详细模式:
python -m doctest -v calculator.py
确认输出中 items had no tests 和实际执行数量是否符合预期。
交互提示符写错
>>> 和 ... 后必须有空格,续行要符合交互式语法。复制 REPL 会话后,应删除无关提示和终端颜色字符。
输出中包含对象地址
不要写死类似 0x10ab... 的地址。优先改写示例,只展示稳定属性;确有教学价值时再局部使用 ELLIPSIS。
示例依赖当前目录
文档测试可能从不同目录启动。使用基于模块文件位置的明确路径,或通过夹具提供临时目录,不要假设当前工作目录永远相同。
示例产生真实副作用
不要让文档测试发送真实邮件、访问生产数据库或修改用户文件。使用无副作用示例、临时资源或测试替身,并把真实连接行为放入受控的集成测试。
为了通过而过度放宽匹配
连续添加 ELLIPSIS、NORMALIZE_WHITESPACE 或 SKIP 会让测试逐渐失去约束。先问清楚变化是否属于接口允许范围;如果不是,应修正文档或代码。
文档测试检查清单
- 示例能否帮助读者理解一个真实用法?
- 输入提示符和续行提示符是否符合交互式语法?
- 期望输出是
repr()结果、print()文本,还是应该没有输出? - 输出顺序、浮点显示和异常消息是否跨目标环境稳定?
- 是否只在确有需要时使用宽松选项?
- 示例能否独立运行,不依赖另一个文档字符串的状态?
- 是否避免网络、生产数据和其他真实副作用?
- 是否用
-v确认过示例确实被发现? - 是否已经接入项目的统一测试命令或持续集成?
- 复杂场景是否交给了更合适的单元测试?
文档测试的最佳用途,是保护那些读者会复制、理解和依赖的示例。保持示例短小、确定、可执行,它就能同时提升文档可信度和代码质量。