纯函数测试基础:无副作用函数编写、测试用例设计、断言方法
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. 纯函数测试流程图
以下流程总结了从函数识别到测试验证的完整步骤:
10. 性能与稳定性优化
- 缓存纯函数结果:使用
functools.lru_cache对重复输入进行结果缓存,大幅提升测试套件速度。 - 并发执行测试:纯函数无竞态条件,可安全启用
pytest -n auto并行运行。 - 测试数据工厂:使用
@pytest.fixture提供干净的测试输入,避免测试间相互污染。 - 基准测试集成:对高频纯函数增加
pytest-benchmark装饰器,监控每次重构后的性能退化。
离线优先的核心:所有纯函数测试都可以在没有网络、没有数据库、没有文件系统的环境中运行。即使中断测试进程,也不会留下脏数据或悬挂资源。