3. Mock的返回值与副作用设置:return_value、side_effect用法、模拟异常抛出
3.1 原理:固定响应 vs. 动态行为
Mock 对象的核心能力在于 行为替代:当被测代码调用某个依赖方法时,开发者可以预先定义该方法的返回结果、执行逻辑甚至异常。这主要通过两个属性实现:
- return_value —— 固定返回值。无论调用多少次,始终返回同一个对象。适用于状态查询、配置读取等幂等场景。
- side_effect —— 动态副作用。优先级高于
return_value,可以是可迭代对象(按序返回)、可调用对象(根据入参计算返回)或异常(强制抛出)。适用于时序依赖、错误注入、状态机模拟。
执行流程可归纳为:
- 判断
side_effect是否不为None。 - 若不为
None,则执行side_effect逻辑;若为可迭代对象,弹出下一个元素;若为可调用对象,调用并返回结果;若为异常类/实例,直接抛出。 - 若
side_effect为None,则返回return_value;若return_value也未设置,则返回新的Mock对象(默认行为)。
关键原则:
side_effect 始终覆盖 return_value。当同时设置两者时,side_effect 优先生效。
3.2 核心实现流程
下表对比了两种机制在典型场景下的差异:
| 特性 | return_value | side_effect |
|---|---|---|
| 目的 | 固定返回值(幂等) | 动态响应、序列依赖、异常模拟 |
| 数据类型 | 任意 Python 对象 | Iterable、Callable、Exception 类或实例 |
| 优先级 | 低(仅 side_effect 为 None 时生效) |
高(只要不为 None 即覆盖 return_value) |
| 典型场景 | 隔离 API 调用、配置查询 | 分页数据、连接超时、状态机 |
| 生命周期 | 无限次有效 | 可迭代对象耗尽后抛出 StopIteration |
标准的实现流程可概括为以下步骤:
- 确定模拟目标 —— 明确需要替换的依赖方法(如
requests.get、database.query)。 - 选择机制 —— 若只需固定应答,使用
return_value;若需要动态或异常行为,使用side_effect。 - 注入 Mock —— 通过
patch或直接赋值将 Mock 对象注入被测模块。 - 验证结果 —— 执行被测函数,断言返回值和调用次数符合预期。
经验建议:当 side_effect 为可迭代对象时,提前计算好所有期望值并放入列表。避免在测试用例内动态修改迭代器,容易导致测试间耦合。
3.3 代码示例与 API 细节
以下示例基于 unittest.mock(Python 标准库),完整展示了四种核心用法:
3.3.1 固定返回值 —— return_value
from unittest.mock import Mock
# 构造一个返回固定字典的 Mock
def get_user_mock(user_id):
mock = Mock()
mock.get_name.return_value = "Alice"
return mock
mock = get_user_mock(1)
result = mock.get_name()
assert result == "Alice"
print(result) # Alice
3.3.2 序列返回值 —— side_effect as Iterable
# 模拟分页查询,每次调用返回不同的页
mock_db = Mock()
mock_db.fetch_page.side_effect = [
["item1", "item2"],
["item3", "item4"],
[]
]
page1 = mock_db.fetch_page()
assert page1 == ["item1", "item2"]
page2 = mock_db.fetch_page()
assert page2 == ["item3", "item4"]
page3 = mock_db.fetch_page()
assert page3 == []
# 第四次调用将抛出 StopIteration
try:
mock_db.fetch_page()
except StopIteration:
print("side_effect 序列已耗尽")
3.3.3 动态计算 —— side_effect as Callable
# 根据输入参数动态计算返回值
def calculate_mock(x, y):
return x * y + 1
mock_calc = Mock()
mock_calc.compute.side_effect = calculate_mock
result = mock_calc.compute(3, 4)
assert result == 13
# 也可以使用 lambda 简化
mock_calc2 = Mock()
mock_calc2.transform.side_effect = lambda s: s.upper()
print(mock_calc2.transform("hello")) # HELLO
3.3.4 模拟异常 —— side_effect as Exception
# 注入特定的异常,模拟外部服务不可用
mock_service = Mock()
mock_service.get_data.side_effect = ConnectionError("Service timeout")
def fetch_user_data():
try:
return mock_service.get_data()
except ConnectionError as e:
return f"Fallback due to {e}"
result = fetch_user_data()
assert result == "Fallback due to Service timeout"
print(result) # Fallback due to Service timeout
常见坑点:当 side_effect 设置为异常类(如
ValueError)而非实例(如 ValueError("msg"))时,Mock 会自动实例化。但若异常类需要特定参数,必须使用实例形式,否则会引发 TypeError。
3.4 调试与验证
在复杂测试场景中,需要通过 Mock 内置属性验证副作用是否正确触发:
call_args—— 最近一次调用的参数。call_args_list—— 所有调用的参数列表。call_count—— 调用次数。method_calls—— 子 Mock 的调用记录。
mock = Mock()
mock.side_effect = [10, 20]
mock(1, a=2)
mock(3, b=4)
assert mock.call_count == 2
assert mock.call_args_list == [call(1, a=2), call(3, b=4)]
assert mock.call_args == call(3, b=4) # 最近一次
若 side_effect 为可调用对象,可在其中添加日志记录:
def trace_side_effect(x):
print(f"[TRACE] called with {x}")
return x * 2
mock = Mock(side_effect=trace_side_effect)
result = mock(5) # 控制台输出 [TRACE] called with 5
assert result == 10
3.5 常见错误与性能考量
| 错误模式 | 后果 | 解决方案 |
|---|---|---|
| side_effect 迭代器提前耗尽 | 调用时抛出 StopIteration |
确保列表长度 ≥ 调用次数,或使用无限迭代器(如 itertools.cycle) |
| 混淆 return_value 与 side_effect 优先级 | 预期固定值但获得动态值 | 确认 side_effect 是否为 None;使用 return_value 前先置空 side_effect |
| side_effect 可调用对象修改外部状态 | 测试间污染,难以定位 | 尽量使用纯函数;若必须修改状态,在 tearDown 中重置 |
| 异常实例未正确初始化 | 抛出 TypeError 而非目标异常 |
始终使用异常实例 ValueError("msg"),而非异常类 ValueError |
性能优化:
- 优先使用
return_value而非side_effect可调用对象,后者每次调用都带来额外函数调用开销。 - 若 side_effect 必须为可调用且被高频调用,考虑将其实现为 C 扩展或缓存计算结果。
- 使用
Mock(spec=SomeClass)限制属性访问,避免误写造成额外性能损耗。
3.6 决策流程总结
下图展示了 Mock 在被调用时的完整决策树,帮助快速定位行为设置:
可视化决策逻辑:从上图可以看出,side_effect 是一级分流;仅当其为 None 时,return_value 才有机会生效。日常调试中可以通过打印
mock.side_effect 和 mock.return_value 快速确认当前配置状态。