文档即测试 —— doctest模块
写函数注解的时候,在文档字符串里除了加参数的说明、函数的返回值,有没有想过,其实还可以在里面写运行代码案例,甚至做简单的单元测试功能?Python的标准库真的提供了一个这样的工具:doctest。
一、核心概念
1.1 基础定义:什么是“文档即测试”?
想象一下你在教朋友玩一个新桌游:
-
• 普通文档:你写了一本规则书,里面说“玩家每次可以抽2张牌” -
• 文档即测试:你不仅写了规则,还附加了一句“比如:小张有3张牌,抽2张后,他现在有5张牌”,而且这句话本身就是可执行的测试
doctest模块做的就是这件事:它扫描你的文档字符串(docstring),找出看起来像Python交互式会话的示例,然后运行这些示例,验证结果是否与文档中写的一致。
核心思想:
-
1. 文档中的示例应该是真实可运行的代码 -
2. 如果示例不能运行或结果不对,说明文档过时了 -
3. 文档和代码永远同步,因为不同步就会测试失败
1.2 基本语法:最简单的doctest
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
# doctest_basic.py
def add_numbers(a, b):
"""
将两个数字相加。
示例:
>>> add_numbers(2, 3)
5
>>> add_numbers(-1, 1)
0
支持浮点数:
>>> add_numbers(1.5, 2.5)
4.0
"""
return a + b
def greet(name, formal=False):
"""
根据形式返回问候语。
基本用法:
>>> greet("Alice")
'Hello, Alice!'
正式问候:
>>> greet("Bob", formal=True)
'Good day, Mr. Bob.'
注意空白字符也会被比较:
>>> greet("Charlie") # 这行只是注释,不会被执行
'Hello, Charlie!'
"""
if formal:
return f"Good day, Mr. {name}."
return f"Hello, {name}!"
if __name__ == "__main__":
# 运行这个文件中的所有doctest
import doctest
doctest.testmod(verbose=True) # verbose=True会显示详细信息
运行这个脚本:
1
python doctest_basic.py
你会看到类似这样的输出:
1 2 3 4 5 6 7 8 9 10 11
Trying:
add_numbers(2, 3)
Expecting:
5
ok
Trying:
add_numbers(-1, 1)
Expecting:
0
ok
...
doctest的语法规则:
-
1. >>>开头的是要执行的代码 -
2. 下一行是期望的输出 -
3. 如果输出匹配,测试通过 -
4. 空行用于分隔不同的测试用例 -
5. 以 #开头的是注释,不会被执行
二、应用场景
2.1 为函数和类编写可测试文档
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97
# doctest_functions_classes.py
"""
数学工具函数集合。
这个模块提供了一些常用的数学计算函数。
"""
def calculate_discount(price, discount_rate):
"""
计算打折后的价格。
参数:
price: 原价
discount_rate: 折扣率,0.0到1.0之间
返回:
折后价格
示例:
>>> calculate_discount(100, 0.2) # 8折
80.0
>>> calculate_discount(50, 0) # 不打折
50.0
>>> calculate_discount(200, 0.5) # 5折
100.0
边界情况:
>>> calculate_discount(0, 0.3) # 0元商品
0.0
"""
if not 0 <= discount_rate <= 1:
raise ValueError("折扣率必须在0到1之间")
return price * (1 - discount_rate)
class BankAccount:
"""
简单的银行账户类。
示例:
>>> account = BankAccount("Alice", 1000)
>>> account.balance
1000
>>> account.deposit(500)
'存入500元,当前余额1500元'
>>> account.withdraw(300)
'取出300元,当前余额1200元'
>>> account.withdraw(2000) # 余额不足
Traceback (most recent call last):
...
ValueError: 余额不足
"""
def __init__(self, owner, initial_balance=0):
self.owner = owner
self.balance = initial_balance
def deposit(self, amount):
"""存款"""
if amount <= 0:
raise ValueError("存款金额必须大于0")
self.balance += amount
return f"存入{amount}元,当前余额{self.balance}元"
def withdraw(self, amount):
"""取款"""
if amount <= 0:
raise ValueError("取款金额必须大于0")
if amount > self.balance:
raise ValueError("余额不足")
self.balance -= amount
return f"取出{amount}元,当前余额{self.balance}元"
def __str__(self):
return f"{self.owner}的账户,余额{self.balance}元"
def run_doctests():
"""运行这个模块的doctest"""
import doctest
# 安静模式,只显示失败信息
print("运行doctest测试...")
result = doctest.testmod(verbose=False)
if result.failed == 0:
print(f"✓ 所有{result.attempted}个测试通过!")
else:
print(f"✗ {result.failed}个测试失败")
if __name__ == "__main__":
run_doctests()
2.2 在模块级别编写文档测试
不仅函数和类可以有doctest,整个模块也可以在文档字符串中包含测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
# math_utils.py
"""
数学工具模块。
这个模块提供一些常用的数学计算功能。
模块级别的示例:
>>> from math_utils import is_prime, fibonacci
>>> is_prime(17)
True
>>> is_prime(9)
False
>>> fibonacci(6) # 斐波那契数列: 0,1,1,2,3,5,8,13...
8
>>> fibonacci(10)
55
"""
def is_prime(n):
"""判断一个数是否为质数。"""
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True
def fibonacci(n):
"""计算第n个斐波那契数。"""
if n < 0:
raise ValueError("n必须是非负整数")
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
# 模块级别的测试用例(不在任何函数内)
"""
测试边缘情况:
>>> is_prime(1)
False
>>> is_prime(2) # 2是质数
True
>>> fibonacci(0)
0
>>> fibonacci(1)
1
"""
if __name__ == "__main__":
import doctest
doctest.testmod()
2.3 最佳实践:编写有效的doctest
原则1:示例应该简单明了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
# good_examples.py
def process_items(items):
"""
处理项目列表。
好的示例(简单直接):
>>> process_items([1, 2, 3])
[2, 4, 6]
不好的示例(太复杂):
>>> process_items([x for x in range(10) if x % 2 == 0])
[0, 4, 8, 12, 16]
# 这个示例本身就需要理解,失去了文档的意义
"""
return [x * 2 for x in items]
def find_max(numbers):
"""
找到列表中的最大值。
好的示例(展示常见情况):
>>> find_max([3, 1, 4, 1, 5, 9])
9
好的示例(展示边缘情况):
>>> find_max([-5, -10, -1])
-1
好的示例(展示错误处理):
>>> find_max([])
Traceback (most recent call last):
...
ValueError: 列表不能为空
"""
if not numbers:
raise ValueError("列表不能为空")
return max(numbers)
原则2:使用ELLIPSIS选项处理可变输出
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
# doctest_ellipsis.py
import random
import datetime
def get_current_info():
"""
获取当前信息。
由于时间和随机性,输出会变化,我们可以用...匹配部分输出:
>>> info = get_current_info()
>>> info['timestamp'].year == datetime.datetime.now().year
True
>>> 0 <= info['random_number'] <= 100
True
>>> info['status']
'ok'
"""
return {
'timestamp': datetime.datetime.now(),
'random_number': random.randint(0, 100),
'status': 'ok'
}
def generate_id():
"""
生成随机ID。
使用ELLIPSIS忽略变化的部分:
>>> generate_id() # doctest: +ELLIPSIS
'ID-...'
更精确的匹配:
>>> id_str = generate_id()
>>> id_str.startswith('ID-') and len(id_str) == 10
True
"""
import uuid
return f"ID-{uuid.uuid4().hex[:6]}"
if __name__ == "__main__":
import doctest
# 启用ELLIPSIS选项
doctest.testmod(optionflags=doctest.ELLIPSIS)
原则3:组织大型测试用例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
# doctest_organization.py
"""
数据处理工具。
这个模块包含多个相关的数据处理函数。
"""
def normalize_data(data):
"""
标准化数据,使所有值在0-1范围内。
示例:
>>> normalize_data([1, 2, 3, 4, 5])
[0.0, 0.25, 0.5, 0.75, 1.0]
>>> normalize_data([10, 20])
[0.0, 1.0]
"""
if not data:
return []
min_val = min(data)
max_val = max(data)
if min_val == max_val:
return [0.5] * len(data)
return [(x - min_val) / (max_val - min_val) for x in data]
def filter_outliers(data, threshold=2):
"""
过滤掉离群值。
基本用法:
>>> filter_outliers([1, 2, 3, 4, 100])
[1, 2, 3, 4]
自定义阈值:
>>> filter_outliers([1, 2, 3, 4, 10], threshold=1.5)
[1, 2, 3, 4]
"""
import statistics
if len(data) < 2:
return data
mean_val = statistics.mean(data)
stdev_val = statistics.stdev(data)
return [
x for x in data
if mean_val - threshold * stdev_val <= x <= mean_val + threshold * stdev_val
]
# 为整个模块编写集成测试
"""
集成测试示例:
>>> data = [1, 2, 3, 100, 4, 5]
>>> filtered = filter_outliers(data)
>>> filtered
[1, 2, 3, 4, 5]
>>> normalized = normalize_data(filtered)
>>> normalized # doctest: +ELLIPSIS
[0.0, 0.25, 0.5, 0.75, 1.0]
"""
if __name__ == "__main__":
import doctest
# 运行测试,显示详细信息
doctest.testmod(verbose=True)
三、高级技巧
3.1 控制测试执行选项
doctest提供了多种选项来控制测试行为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
# doctest_options.py
"""
测试选项演示。
这个模块展示如何使用不同的doctest选项。
"""
def example_with_output():
"""
有输出的函数示例。
默认情况下,doctest会精确比较输出:
>>> print("Hello\\nWorld")
Hello
World
使用NORMALIZE_WHITESPACE可以忽略空白差异:
>>> ["a", "b", "c"] # doctest: +NORMALIZE_WHITESPACE
['a', 'b', 'c']
注意上面列表的表示,实际输出是['a', 'b', 'c'],但空格可能不同
"""
pass
def example_with_exceptions():
"""
异常处理示例。
测试异常时,我们通常只关心异常类型:
>>> 1 / 0
Traceback (most recent call last):
ZeroDivisionError: division by zero
使用ELLIPSIS可以只匹配部分异常信息:
>>> int("not a number") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
ValueError: ...
"""
pass
def example_with_float():
"""
浮点数比较示例。
浮点数比较有精度问题:
>>> 0.1 + 0.2 # 这实际上等于0.30000000000000004
0.30000000000000004
我们可以允许一定的误差:
>>> abs((0.1 + 0.2) - 0.3) < 0.000001
True
或者使用approx函数:
>>> from math import isclose
>>> isclose(0.1 + 0.2, 0.3)
True
"""
pass
# 模块级别的选项设置
"""
全局设置选项:
>>> import doctest
>>> doctest.set_unittest_reportflags(doctest.REPORT_NDIFF)
"""
if __name__ == "__main__":
import doctest
# 设置多个选项
options = (
doctest.ELLIPSIS | # 允许使用...匹配任意输出
doctest.NORMALIZE_WHITESPACE | # 忽略空白差异
doctest.IGNORE_EXCEPTION_DETAIL # 忽略异常详情,只检查类型
)
# 运行测试
result = doctest.testmod(optionflags=options, verbose=True)
print(f"\n测试结果: {result.attempted}个测试尝试,{result.failed}个失败")
3.2 在测试文件中使用doctest
除了在代码文件中嵌入doctest,你还可以创建独立的测试文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
# test_example.txt
"""
这是独立的doctest文件。
我们可以在这里写测试,而不影响源代码文件。
>>> from math_utils import is_prime
>>> is_prime(2)
True
>>> is_prime(4)
False
>>> is_prime(17)
True
>>> from math_utils import fibonacci
>>> fibonacci(0)
0
>>> fibonacci(1)
1
>>> fibonacci(5)
5
>>> fibonacci(10)
55
"""
然后创建一个Python脚本来运行这个测试文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
# run_doctest_file.py
"""
运行独立的doctest文件。
"""
import doctest
import sys
def test_text_file():
"""测试文本文件中的doctest"""
print("测试独立的doctest文件...")
# 运行文本文件中的测试
result = doctest.testfile(
"test_example.txt",
optionflags=doctest.ELLIPSIS,
verbose=True
)
return result
def test_external_module():
"""测试外部模块的doctest"""
print("\n测试外部模块的doctest...")
# 假设我们有一个math_utils.py模块
try:
import math_utils
result = doctest.testmod(math_utils, verbose=False)
print(f"math_utils测试: {result.attempted}尝试,{result.failed}失败")
except ImportError:
print("找不到math_utils模块,跳过测试")
if __name__ == "__main__":
# 测试文本文件
result1 = test_text_file()
# 测试外部模块
test_external_module()
# 总结
if result1.failed == 0:
print("\n✓ 所有测试通过!")
else:
print(f"\n✗ 有{result1.failed}个测试失败")
sys.exit(1)
四、注意事项
4.1 使用限制
-
1. 不适合复杂测试:doctest适合简单的示例验证,不适合复杂的测试逻辑 -
2. 输出必须精确:默认情况下,输出必须完全匹配,包括空格和换行 -
3. 有副作用:示例代码会实际执行,可能有副作用 -
4. 性能考虑:文档中的示例每次运行测试都会执行 -
5. 可读性:过多的测试示例可能影响文档的可读性
4.2 常见问题
Q: doctest和unittest哪个更好?
A: 不是”更好”,而是用途不同:
-
• doctest:用于验证文档中的示例,确保文档正确 -
• unittest:用于编写全面的测试套件,包括各种边界情况
Q: 如何处理随机输出或变化输出?
A: 使用ELLIPSIS选项,或者用条件判断而不是精确匹配:
1 2 3
>>> import random
>>> random.randint(1, 100) # doctest: +ELLIPSIS
...
Q: doctest能测试私有函数吗?
A: 可以,但不建议。文档测试应该关注公共API。
Q: 测试失败时如何调试?
A: 使用doctest.testmod(verbose=True)查看详细输出,或者用pdb调试失败的测试。
Q: 如何在CI/CD中运行doctest?
A: 在测试命令中添加python -m doctest your_module.py,或者通过unittest运行。
4.3 替代方案
-
1. unittest/pytest:需要全面的测试覆盖时 -
2. 文档生成工具:Sphinx的 autodoc扩展可以自动生成API文档 -
3. 示例代码库:维护独立的使用示例库 -
4. 交互式教程:Jupyter Notebook作为交互式文档 -
5. 类型提示:配合mypy进行静态类型检查
何时选择替代方案:
-
• 需要测试覆盖率报告 → 使用pytest -
• 大型项目需要完整测试套件 → 使用unittest -
• 需要生成HTML文档 → 使用Sphinx -
• 需要交互式教学 → 使用Jupyter Notebook -
• 需要静态类型检查 → 使用mypy + 类型提示
下期预告:
学会了如何用doctest确保文档的正确性,下一期我们将进入Python另一个核心的领域:文件的读写。
你在项目中用过doctest吗?有什么有趣的经验或教训?或者你认为文档测试在实际项目中到底有多大价值?欢迎在评论区分享你的看法和经验!
关注我,免费分享Python学习资料!探索的乐趣,在于分享和发现。别忘了在评论区留下你的想法和问题,我们下期见!
夜雨聆风