乐于分享
好东西不私藏

文档即测试 —— doctest模块

文档即测试 —— doctest模块

写函数注解的时候,在文档字符串里除了加参数的说明、函数的返回值,有没有想过,其实还可以在里面写运行代码案例,甚至做简单的单元测试功能?Python的标准库真的提供了一个这样的工具:doctest

一、核心概念

1.1 基础定义:什么是“文档即测试”?

想象一下你在教朋友玩一个新桌游:

  • • 普通文档:你写了一本规则书,里面说“玩家每次可以抽2张牌”
  • • 文档即测试:你不仅写了规则,还附加了一句“比如:小张有3张牌,抽2张后,他现在有5张牌”,而且这句话本身就是可执行的测试

doctest模块做的就是这件事:它扫描你的文档字符串(docstring),找出看起来像Python交互式会话的示例,然后运行这些示例,验证结果是否与文档中写的一致。

核心思想

  1. 1. 文档中的示例应该是真实可运行的代码
  2. 2. 如果示例不能运行或结果不对,说明文档过时了
  3. 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. 1. >>>开头的是要执行的代码
  2. 2. 下一行是期望的输出
  3. 3. 如果输出匹配,测试通过
  4. 4. 空行用于分隔不同的测试用例
  5. 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.01.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. 1. 不适合复杂测试:doctest适合简单的示例验证,不适合复杂的测试逻辑
  2. 2. 输出必须精确:默认情况下,输出必须完全匹配,包括空格和换行
  3. 3. 有副作用:示例代码会实际执行,可能有副作用
  4. 4. 性能考虑:文档中的示例每次运行测试都会执行
  5. 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. 1. unittest/pytest:需要全面的测试覆盖时
  2. 2. 文档生成工具:Sphinx的autodoc扩展可以自动生成API文档
  3. 3. 示例代码库:维护独立的使用示例库
  4. 4. 交互式教程:Jupyter Notebook作为交互式文档
  5. 5. 类型提示:配合mypy进行静态类型检查

何时选择替代方案

  • • 需要测试覆盖率报告 → 使用pytest
  • • 大型项目需要完整测试套件 → 使用unittest
  • • 需要生成HTML文档 → 使用Sphinx
  • • 需要交互式教学 → 使用Jupyter Notebook
  • • 需要静态类型检查 → 使用mypy + 类型提示

下期预告

学会了如何用doctest确保文档的正确性,下一期我们将进入Python另一个核心的领域:文件的读写。

往期推荐
单元测试框架 —— unittest模块

优雅的交互式调试器 —— pdb 和 breakpoint

告别print调试 —— logging模块


你在项目中用过doctest吗?有什么有趣的经验或教训?或者你认为文档测试在实际项目中到底有多大价值?欢迎在评论区分享你的看法和经验!

关注我,免费分享Python学习资料!探索的乐趣,在于分享和发现。别忘了在评论区留下你的想法和问题,我们下期见!