纯函数测试基础:无副作用函数编写、测试用例设计、断言方法

1. 纯函数核心原理

纯函数是离线优先单元测试最理想的被测对象。它在数学和计算上满足两个必要条件:

  • 确定性(Deterministic):相同输入始终产生相同输出,不依赖任何外部状态或随机数。
  • 无副作用(Side-Effect Free):函数执行过程中不修改任何外部变量、不执行 I/O、不改变参数对象、不抛出未处理的异常。

只有满足这两条的函数才能做到“一次测试,到处回放”,真正离线、无网络、无基础设施依赖。

2. 纯函数的特征对照表

特征 纯函数 非纯函数
输入输出映射 固定映射,可预计算 依赖外部系统时间/随机数
外部状态依赖 不依赖全局变量、文件、DB 依赖配置文件或全局单例
可变参数处理 不修改入参对象 修改入参或外部列表
可缓存性 结果可安全缓存 缓存可能失效
并发安全 天然线程安全 需加锁防护

3. 测试用例设计策略

纯函数测试用例围绕等价类划分和边界值分析展开,不需要 mock 或 stub。

  • 正常输入类:典型值组合,验证预期输出。
  • 边界输入类:零、空集合、最大整数、浮点精度临界点。
  • 错误输入类:非法值、空指针、负数(当函数预期正数时)。
  • 特殊逻辑路径:分支条件全覆盖(真/假分支)。
经验法则:一个纯函数的测试用例数量 ≈ 函数中独立分支数 × 2(正常+异常) + 边界场景数。例如一个包含 if/else 和一个数学运算的函数,至少需要 4-6 个用例。

4. 断言方法的选型

在离线优先的纯函数测试中,断言风格应保持简单、直接、可离线执行:

  • 值断言:assertEqual / assertEquals 验证精确返回值。
  • 浮点近似断言:assertAlmostEqual 或设置 delta 参数。
  • 集合断言:assertListEqual / assertSetEqual 验证无顺序或有序结果。
  • 异常断言:assertRaises 验证函数在非法输入时抛出指定异常。
  • 引用透明断言:对同一输入多次调用并断言输出恒等。

5. 代码示例:纯函数与测试编写

以下是一个计算折扣金额的纯函数及对应的离线测试用例(Python + pytest):

# discount.py
def calculate_discount(price: float, rate: float) -> float:
    """纯函数:根据原价和折扣率计算折后价,四舍五入到两位小数。"""
    if price < 0 or rate < 0 or rate > 1:
        raise ValueError("price must be >=0, rate must be in [0, 1]")
    return round(price * (1 - rate), 2)

# test_discount.py
import pytest
from discount import calculate_discount

def test_calculate_discount_normal():
    # 正常值:满100打8折
    result = calculate_discount(100.0, 0.2)
    assert result == 80.0

def test_calculate_discount_zero_rate():
    # 边界值:折扣率为0,折后等于原价
    assert calculate_discount(50.0, 0.0) == 50.0

def test_calculate_discount_full_discount():
    # 边界值:折扣率1,折后价为0
    assert calculate_discount(99.99, 1.0) == 0.0

def test_calculate_discount_float_precision():
    # 浮点精度:测试四舍五入
    assert calculate_discount(19.99, 0.15) == 16.99

def test_calculate_discount_negative_price():
    # 非法输入:负价格应抛出异常
    with pytest.raises(ValueError):
        calculate_discount(-10.0, 0.1)

def test_calculate_discount_invalid_rate():
    # 非法输入:折扣率超出[0,1]范围
    with pytest.raises(ValueError):
        calculate_discount(50.0, 1.5)

def test_calculate_discount_reference_transparent():
    # 引用透明性验证:相同输入多次调用结果一致
    inputs = (200.0, 0.25)
    results = [calculate_discount(*inputs) for _ in range(5)]
    assert all(r == results[0] for r in results)

6. 副作用隔离与函数重构

当业务逻辑与副作用(日志、网络、数据库)混写时,应使用函数式核心 / 命令式外壳模式将纯函数萃取出来:

常见错误:直接在测试中依赖外部 I/O 或全局时间,导致测试无法离线运行。应始终将随机数、系统时间、数据库连接作为参数注入或抽离到外层。
# 不推荐:副作用混入逻辑
def process_order(order_id):
    db = get_database()
    order = db.fetch(order_id)  # 副作用:网络
    return compute_total(order) # 纯逻辑

# 推荐:剥离纯函数
def compute_total(order_items: list) -> float:
    """纯函数:离线可测试"""
    return sum(item.price * item.quantity for item in order_items)

# 外层只负责副作用
def process_order(order_id, db):
    order = db.fetch(order_id)
    total = compute_total(order.items)
    return total

7. 测试质量指标

  • 分支覆盖率 ≥ 100%:纯函数不含隐式分支(如异常处理分支),必须全量覆盖。
  • 边界值覆盖率 ≥ 80%:对每个数值参数,至少包含最小值、最大值、典型值。
  • 引用透明测试 ≥ 1个:确保函数无隐藏内部状态。
  • 单测执行时间 < 1ms:纯函数无 I/O,单个用例应在微秒级完成。

8. 常见错误与调试

错误场景 根因 调试方法
测试依赖外部配置文件 函数内引用全局 config 对象 将配置作为参数注入
浮点断言反复失败 直接使用 == 比较浮点 改为 assertAlmostEqual / abs(a-b) < 1e-6
输入对象被修改 函数内改变了入参属性 使用不可变类型或深拷贝防御
随机测试结果不稳定 函数内使用了 random 模块 将随机数生成器作为参数传入
调试技巧:在纯函数内部插入 print() 不影响测试——因为纯函数不依赖外部状态,打印仅用于开发者观察,不会改变函数输出。这是与非纯函数调试的重要区别。

9. 纯函数测试流程图

以下流程总结了从函数识别到测试验证的完整步骤:

纯函数测试流程图 1. 识别纯函数 2. 设计等价类+边界值 3. 选取精确断言方法 4. 编写离线测试代码 5. 执行 & 验证覆盖率 6. 副作用隔离检查 关键检查项 • 无 I/O 或网络调用 • 相同输入相同输出 • 不修改全局/入参

10. 性能与稳定性优化

  • 缓存纯函数结果:使用 functools.lru_cache 对重复输入进行结果缓存,大幅提升测试套件速度。
  • 并发执行测试:纯函数无竞态条件,可安全启用 pytest -n auto 并行运行。
  • 测试数据工厂:使用 @pytest.fixture 提供干净的测试输入,避免测试间相互污染。
  • 基准测试集成:对高频纯函数增加 pytest-benchmark 装饰器,监控每次重构后的性能退化。
离线优先的核心:所有纯函数测试都可以在没有网络、没有数据库、没有文件系统的环境中运行。即使中断测试进程,也不会留下脏数据或悬挂资源。