doctest模块 — 文档测试

Python标准库精讲专题 · 测试与调试篇 · 掌握文档测试工具

专题:Python标准库精讲系统学习

关键词:Python, 标准库, doctest, 文档测试, 测试, 文档字符串, testmod, testfile

一、doctest概述 — 可执行的文档

doctest是Python标准库中一个轻量级的测试框架,它的核心理念是"可执行的文档"(Executable Documentation)。与传统的将测试代码和文档分离的做法不同,doctest允许开发者将测试用例直接嵌入到文档字符串(docstring)中,使得文档本身就是一个可验证的测试套件。

doctest的设计哲学源于Tim Peters和Guido van Rossum对Python代码可读性的追求。它的工作方式非常直观:扫描文档字符串中所有看起来像是Python交互式解释器会话的文本片段(以>>>开头),然后执行这些代码片段,并将实际输出与文档中预期的输出进行比对。

doctest的核心价值

doctest解决了传统测试和文档编写中常见的几个痛点。其一,文档和测试的统一意味着当代码接口发生变化时,开发者必须同时更新文档中的示例,有效避免了文档过时的问题。其二,低学习成本使得即使是测试新手也能快速上手,无需学习独立的测试框架语法。其三,doctest天然适合作为API的入门教程,因为文档中的示例代码总是经过验证的。

"doctest不是要取代unittest,而是提供了一个互补的、轻量级的选择,特别适合测试简单的函数行为和文档中的示例代码。" — Python官方文档

适用场景

局限性

二、基本使用 — 文档字符串中写测试

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 + pytestdoctest验证文档示例,pytest覆盖完整逻辑
教学示例代码doctest读者可亲自运行,确保教学代码正确
第三方库接口示例doctest降低使用门槛,示例本身就是测试

常见陷阱与规避

检查清单