用 pytest --doctest-modules 让文档示例参与测试
用 pytest --doctest-modules 让文档示例参与测试
问题场景
函数文档字符串里的示例代码,常常在第一次编写时是正确的;后续修改返回值、字段名或输出格式时,示例没有同步更新,文档就变成过时示例。读者复制 >>> 示例运行后得到不同结果,排查成本很高。把文档示例纳入自动化测试,可以让这类问题在提交前暴露。
解决思路
pytest 内置 doctest 插件,可以收集模块、类、函数文档字符串中的交互式示例,并按测试执行。开启 --doctest-modules 后,pytest 会导入目标模块并收集其中的 doctest。再通过 pytest.ini 配置 doctest_optionflags,用 ELLIPSIS 处理随机 id、时间戳等动态片段,用 NORMALIZE_WHITESPACE 减少空白差异导致的误报。对于第三方目录、构建产物目录,用 --ignore 排除,避免 pytest 导入无关模块。
最小可运行示例
目录结构如下,示例值可按实际项目替换:
project/
├── pytest.ini
└── src/
└── mathlib.py
src/mathlib.py:
'''小工具模块:文档字符串中的示例同时作为 doctest 执行。'''
from uuid import uuid4
def add(a, b):
'''返回两个整数之和。
>>> add(2, 3)
5
>>> add(-1, 1)
0
'''
return a + b
def build_payload(name):
'''构造带随机 id 的载荷。
>>> build_payload('demo') # doctest: +ELLIPSIS
{'id': '...', 'name': 'demo'}
'''
return {'id': uuid4().hex, 'name': name}
def split_tags(raw):
'''拆分逗号分隔的标签,并忽略空白项。
>>> split_tags('a, b,,c ')
['a', 'b', 'c']
>>> split_tags('')
[]
'''
return [item.strip() for item in raw.split(',') if item.strip()]
这里 build_payload 的 id 每次不同,所以示例用 ... 匹配动态部分。代码中保留了 # doctest: +ELLIPSIS 指令,便于单独运行 doctest 时也能通过;如果已在 pytest.ini 全局开启 ELLIPSIS,该行可以省略。
pytest.ini:
[pytest]
testpaths = src
addopts = --doctest-modules --ignore=third_party --ignore=build
doctest_optionflags = ELLIPSIS NORMALIZE_WHITESPACE
testpaths 限定只从 src 收集,addopts 让每次运行 pytest 都开启模块 doctest 收集,--ignore 排除第三方和构建目录。doctest_optionflags 同时开启省略号匹配与空白归一化。
运行与验证
在项目根目录执行:
python -m pytest -q
也可以只验证单个模块:
python -m pytest src/mathlib.py -q
或者显式传入参数,便于在脚本中复现:
python -m pytest --doctest-modules src/mathlib.py -q
若想看到每个 doctest 的收集情况,可加 -v。验证测试有效性时,可临时把 add 的实现改成 return a - b,再运行 pytest,add(2, 3) 示例会因期望 5、实际 -1 而失败;确认后恢复实现。
边界与限制
- doctest 比较的是输出字符串,不是对象语义。字典通常按插入顺序展示,集合顺序不稳定,建议在示例中使用
sorted(...)或只展示稳定字段。 - 浮点数要避免直接比较完整小数。可在示例中先
round(value, 2),或使用固定输入与可预测输出。 - 异常示例需要写出
Traceback (most recent call last):与末尾异常行,中间细节可用...匹配;不要只写异常类型而省略 Traceback 头。 ELLIPSIS很方便,但省略范围过大可能掩盖错误。动态 id、时间戳可以省略,业务字段、关键数值应保持明确。--doctest-modules会导入testpaths下的 Python 模块,模块顶层如果执行网络请求、数据库连接、读取本地文件或启动服务,会在收集阶段产生副作用。此类模块应拆分出纯函数,或用--ignore排除。- doctest 不适合替代完整单元测试。它没有 pytest fixture、参数化和 monkeypatch 等能力;复杂依赖注入、临时目录、异常分支,仍应写在普通测试函数中。
# doctest: +SKIP可以临时跳过示例,但应说明原因并定期清理,避免文档示例长期不验证。- 在 CI 中使用固定命令
python -m pytest -q,确保本地与流水线都读取同一份pytest.ini,减少配置差异。
按以上方式,文档示例会随着 pytest 运行被持续验证,函数行为变更时能及时暴露过时文档。
- 点赞
- 收藏
- 关注作者
评论(0)