🧪 Python 文档测试(doctest)超实用入门

你有没有碰到过这种“名场面”:

  • 辛辛苦苦写好的接口文档,示例代码拷贝下来居然报错;
  • 改完代码忘了更新文档里的例子,用户照着敲出一堆异常;
  • 为了验证示例又写了一套单元测试,结果两边重复维护,身心俱疲。

别急,Python 自带一个「隐藏王牌」——doctest 模块,它把可执行的测试用例直接嵌进文档字符串(docstring)里,看起来像一段真实的 Python 交互式对话。既能当文档看,又能一键跑测试, 真正的「写一次,用两遍」!


为什么选择 doctest?

doctest 不是万能的测试框架,但在“轻量、可读、零依赖”这个赛道上,几乎没有对手:

  1. ✅ 自文档化代码
    最好的教程,就是可以运行并验证的代码。doctest 让你的示例本身就是测试用例,不需要单独维护文档和测试。

  2. 🔄 强制文档同步
    代码逻辑一变,假如文档示例没跟上,测试立刻失败,再也不用担心“过时文档”。

  3. 📦 开箱即用,零依赖
    Python 标准库自带,不需要安装 pytest 或 unittest,随手就能写。

  4. 🤝 无缝对接文档生成器
    Sphinx、MkDocs 等工具可以直接提取 docstring 里的 doctest 示例,生成漂亮的 API 文档,还能顺带自动跑测试。


🔧 基础玩法:三步上手

1. 在函数 / 类的 docstring 里写「仿真对话」

doctest 的语法非常简单,就像把 Python 解释器里的操作搬进多行注释:

  • >>> 开头表示测试输入
  • 紧接着的下一行是预期输出(如果没有输出就空着,但要保持输入/输出的顺序和真实交互一致);
  • 如果测试异常,保留 traceback 的首尾行,中间部分用 ... 省略。

① 简单函数测试

下面是一个自带文档和测试的绝对值函数:

def my_abs(n):
    """
    返回数字的绝对值

    日常使用示例:
    >>> my_abs(1)
    1
    >>> my_abs(-100)
    100
    >>> my_abs(0)  # 边界值也测一下
    0
    """
    return n if n >= 0 else (-n)

② 带exception-handling的类测试

再来一个可以「像访问属性一样访问字典值」的小工具:

class AttrDict(dict):
    """
    支持 .key 和 ['key'] 两种访问方式的字典

    常用操作:
    >>> ad = AttrDict(name='Bob', age=25)
    >>> ad.name
    'Bob'
    >>> ad['age']
    25
    >>> ad.city = 'Beijing'
    >>> ad['city']
    'Beijing'

    exception-handling:
    >>> ad.nonexistent_key
    Traceback (most recent call last):
        ...
    AttributeError: 'AttrDict' object has no attribute 'nonexistent_key'
    """

    def __getattr__(self, key):
        try:
            return self[key]
        except KeyError:
            raise AttributeError(f"'AttrDict' object has no attribute '{key}'")

    def __setattr__(self, key, value):
        self[key] = value

提示... 会帮我们省略 traceback 中间的无关细节,只要首尾信息匹配,测试就算通过。


2. 两种方式运行 doctest

方式一:在模块内部调用(适合快速自测)

在 Python 文件的最底部加上这两行代码:

if __name__ == '__main__':
    import doctest
    doctest.testmod(verbose=True)   # verbose=True 会打印详细的测试过程

然后直接运行这个文件:

python my_module.py

不加 verbose 参数的话,遵循「无输出即通过」的 Unix 哲学——测试全部通过就安安静静,有错误才会报出来。

方式二:命令行直接调用(适合批量 / 临时测试)

不修改任何代码,一条命令搞定:

# 测试单个文件,-v 显示详细信息
python -m doctest my_module.py -v

还可以直接传入一段带 >>> 的文本字符串,或者配合管道使用,非常灵活。


🚀 进阶技巧:处理“不听话”的输出

写 doctest 时,总会遇到一些输出“不太老实”的情况,比如:

  • 内存地址每次运行都不一样;
  • 打印时间随系统变动;
  • 随机数没法预测。

别硬刚,doctest 准备了几个贴心的标记指令。

1. 动态/不可预测的输出?用 ELLIPSIS

在语句后面加上 # doctest: +ELLIPSIS,然后用 ... 代替不确定的部分,doctest 就会“通融”地只匹配你写出的内容,忽略那些变化的部分:

import sys

def show_python_version():
    """
    返回 Python 主版本号

    >>> show_python_version()  # doctest: +ELLIPSIS
    3...
    """
    return sys.version_info.major

上面例子中,只要输出以 3 开头,后面跟了零个或多个字符,测试就通过——无论是 3.8、3.11 还是 3.12。

2. 完全不需要测试的输出?用 SKIP

有些示例纯粹是给读者看的,比如随机数展示,根本没法固定预期输出。直接加 # doctest: +SKIP 跳过验证:

import random

def get_random_num():
    """
    返回 [0, 1) 之间的随机浮点数

    仅示意,无需测试:
    >>> get_random_num()  # doctest: +SKIP
    0.123456789
    """
    return random.random()

3. 多行输出要怎么对齐?

doctest 匹配多行输出时,只关心每一行开头的空格和正文内容,所以只要你把真实输出“原样”抄进去就行,不用纠结绝对位置。例如 print() 打出多段文字的场景:

def print_greeting(name):
    """
    打印多行问候语

    >>> print_greeting('Alice')
    Hello, Alice!
    How are you today?
    Have a nice day!
    """
    print(f"Hello, {name}!")
    print("How are you today?")
    print("Have a nice day!")

🎯 综合实战:一个全覆盖的阶乘函数

下面我们结合常规用例、边界值、异常测试,写一个完整、健壮的阶乘函数。这个例子几乎涵盖了前面讲到的所有知识点:

def factorial(n):
    """
    计算正整数的阶乘(n! = 1 × 2 × … × n)

    常规测试:
    >>> factorial(1)
    1
    >>> factorial(5)
    120
    >>> factorial(10)
    3628800

    非法输入测试:
    >>> factorial(0)
    Traceback (most recent call last):
        ...
    ValueError: n must be a positive integer
    >>> factorial(-3)
    Traceback (most recent call last):
        ...
    ValueError: n must be a positive integer
    """
    if not isinstance(n, int) or n < 1:
        raise ValueError("n must be a positive integer")
    if n == 1:
        return 1
    return n * factorial(n - 1)

if __name__ == '__main__':
    import doctest
    doctest.testmod(verbose=True)

小练习:试着把上面代码保存为 factorial.py,运行一下,看看 doctest 的输出长什么样。


💡 最佳实践

  1. ✅ 保持测试小而精
    每个 >>> 块只测一个功能点,清晰易维护,不用挤在一句话里测所有场景。

  2. ⚠️ 一定要测边界值
    最小值、最大值、空输入、特殊字符……这些往往是 bug 的藏身之处。

  3. ❌ 避免有副作用的测试
    不要在 doctest 里改全局变量、读写磁盘、连接数据库。这类操作会让测试结果不稳定,甚至污染环境。

  4. 🤝 和单元测试框架搭档使用
    doctest 擅长“文档示例验证”,不适合复杂业务逻辑、性能测试等场景。该用 pytest / unittest 的地方就果断用。

  5. 📝 写对用户真正有用的示例
    不要只测 1 + 1,要展示用户实际会遇到的典型用法,文档的价值才能最大化。


📚 对接主流文档生成工具

doctest 的另一大优势在于,它可以和 Sphinx、MkDocs Material 等工具无缝集成,让“写文档”和“跑测试”变成一件事。

Sphinx 配置示例

在 Sphinx 项目的 conf.py 中启用相关扩展:

extensions = [
    'sphinx.ext.doctest',   # 核心:自动运行 doctest
    'sphinx.ext.autodoc',   # 自动提取 docstring
    # 其他扩展...
]

然后在项目根目录运行:

make doctest

这一条命令不仅会测试所有 Python 模块里的 docstring,连 .rst 文档里手写的 >>> 示例也会一并验证,真正做到“文档即测试”。


🎬 总结

Python 的 doctest 是一个「性价比极高」的轻量级工具:

  • 语法简单,不需要额外学习;
  • 同时解决“文档示例同步”和“快速自测”两个痛点;
  • 还能和专业文档工具联动,让文档真正“活”起来。

下次写完一个函数,别急着关文件,花两分钟在 docstring 里补几行 >>> 示例吧。
让你写出来的例子,再也不会坑自己,更不会坑用户!