第 41 课 - Mock 测试与模拟对象
学习目标
完成本课学习后,你将能够:
- 理解 Mock 的概念与作用:明白为什么在单元测试中需要模拟对象,以及它如何帮助我们隔离被测试的代码。
- 掌握
unittest.mock的核心工具:熟练使用Mock,MagicMock,patch装饰器/上下文管理器。 - 为外部依赖编写测试:学会模拟网络请求、数据库查询、文件系统等外部交互,使测试快速、可靠且无副作用。
- 验证交互行为:使用
assert_called_*系列方法检查模拟对象是否被正确调用。
核心概念
1. 什么是 Mock?
想象你在拍摄一部动作电影。主角(你的代码)要完成一场复杂的打斗戏(复杂的业务逻辑)。但在拍摄主角打一个反派(调用一个外部服务或复杂组件)的特写镜头时,导演不会真的让主角去打演员。他们会用一个"替身"或"假人"(Mock 对象)来代替,这样可以专注于拍摄主角的动作和表情,而不受替身演员自身因素(如真实打斗的不可预测性、伤害风险、拍摄进度)的影响。
在编程中,Mock(模拟)对象 就是代码中的"替身"。它用于替代真实的、复杂的、不可靠的或依赖外部环境的组件(如数据库、网络API、文件系统、硬件设备),以便我们能独立、快速、可控地测试我们的核心逻辑。
2. 为什么需要 Mock?
- 隔离测试:我们只想测试"主角"(一个函数或类)的逻辑是否正确,而不关心"替身"(外部依赖)是否正常工作。
- 提高速度:真实的数据库查询或网络请求可能很慢。用 Mock 可以瞬间返回预设结果。
- 测试难以触发的情况:如何模拟一个网络连接失败或一个特定的错误码?Mock 可以轻松设置。
- 避免副作用:测试不应真的发送邮件、修改数据库或支付款项。Mock 可以"假装"做了这些事。
3. Python 的 Mock 工具箱:unittest.mock
Python 的标准库 unittest.mock 提供了强大的 Mock 功能。核心成员包括:
Mock:一个基础的模拟对象。你访问它的任何属性或调用它的任何方法,它都会返回一个新的Mock实例,并记录下这次访问/调用。MagicMock:Mock的子类,更强大。它对 Python 的魔术方法(如__str__,__len__)提供了默认实现,能更好地模拟真实对象的行为。patch:这是最常用、最核心的工具。它用于临时替换("打补丁")测试作用域内的某个对象(如一个类、一个函数)为一个Mock对象,测试结束后自动恢复原样。
代码示例
假设我们有一个模块 weather_service.py,它包含一个函数,用于从真实的网络API获取天气信息。
weather_service.py (被测试的代码)
import requests
def get_city_temperature(city: str) -> float:
"""从天气API获取指定城市的温度。"""
# 假设这是一个真实的API调用
response = requests.get(f"http://weather-api.example.com?city={city}")
response.raise_for_status() # 如果请求失败则抛出异常
data = response.json()
return data['temperature']
现在我们要测试另一个模块 app.py 中使用了 get_city_temperature 的函数。
app.py (我们的业务逻辑)
from weather_service import get_city_temperature
def check_weather_alert(city: str, threshold: float = 35.0) -> str:
"""检查城市温度是否超过阈值,并返回警告信息。"""
current_temp = get_city_temperature(city)
if current_temp > threshold:
return f"警告:{city}温度{current_temp}℃,已超过{threshold}℃!"
else:
return f"{city}天气正常,温度{current_temp}℃。"
问题:我们如何测试 check_weather_alert 函数?直接调用它会发起真实的网络请求,这会使测试变慢、依赖外部服务、且无法稳定测试"高温警告"的场景。
解决方案:使用 unittest.mock 来模拟 get_city_temperature 函数。
test_app.py (测试文件)
import unittest
from unittest.mock import patch, MagicMock
from app import check_weather_alert
class TestCheckWeatherAlert(unittest.TestCase):
# 使用 @patch 装饰器,在运行测试方法期间,将 'app.get_city_temperature' 替换为一个 Mock 对象。
# 替换后的对象(mock_temp_service)会作为第一个参数注入到测试方法中。
@patch('app.get_city_temperature')
def test_high_temperature_returns_warning(self, mock_temp_service):
# 设置 Mock 的返回值。模拟函数返回一个高温值。
mock_temp_service.return_value = 36.5
# 调用被测试的函数,它现在会使用我们的 Mock 而不是真实的服务。
result = check_weather_alert("北京")
# 断言:检查业务逻辑是否正确处理了高温情况。
self.assertIn("警告", result)
self.assertIn("36.5", result)
# 断言:检查我们的 Mock 函数是否被以正确的参数调用了一次。
mock_temp_service.assert_called_once_with("北京")
@patch('app.get_city_temperature')
def test_normal_temperature_returns_normal_message(self, mock_temp_service):
mock_temp_service.return_value = 25.0
result = check_weather_alert("上海")
self.assertIn("正常", result)
self.assertIn("25.0", result)
mock_temp_service.assert_called_once_with("上海")
# 我们甚至可以测试当天气服务抛出异常时,我们的应用如何处理。
@patch('app.get_city_temperature')
def test_service_unavailable(self, mock_temp_service):
# 设置 Mock 在被调用时抛出一个 ConnectionError 异常。
mock_temp_service.side_effect = ConnectionError("网络连接失败")
# 这里需要测试我们的代码是否有适当的错误处理(例如返回一个友好的错误信息)。
# 假设我们的 check_weather_alert 没有处理这个异常,那么它会将异常向上抛出。
# 我们可以用 assertRaises 来验证这一点。
with self.assertRaises(ConnectionError):
check_weather_alert("广州")
if __name__ == '__main__':
unittest.main()
运行测试:python -m unittest test_app -v
实践练习
练习 1:基础 Mocking
- 创建一个函数
get_random_color(),它模拟调用一个不存在的外部颜色库,并返回 "red"。 - 编写测试,使用
@patch装饰器来模拟这个外部调用,验证你的函数是否被正确调用并返回了预设值。 提示:你需要模拟的可能是一个模块中的函数,例如my_colors_lib.get_random。
练习 2:模拟文件操作
你有一个函数 read_config(filename),它使用 Python 内置的 open() 读取配置文件。写一个测试,在不创建真实文件的情况下,模拟 open() 函数返回一个特定的字符串内容。
预期输出:测试通过,且没有真实的文件 I/O 操作发生。
关键:如何用 @patch('builtins.open', ...) 来模拟内置函数?
练习 3:复杂场景 - 模拟类的方法
假设你有一个 PaymentProcessor 类,其 charge_user(user_id, amount) 方法内部会调用第三方支付API StripeClient.charge(amount)。写一个测试,模拟 StripeClient 的实例及其 charge 方法,以验证你的 PaymentProcessor 是否正确地将参数传递给了支付网关。
常见错误
- Patch 的目标位置错误:初学者最常犯的错误。你必须
patch你代码使用它的位置,而不是它定义的位置。- 错误:
@patch('weather_service.get_city_temperature')(在app.py的测试中) - 正确:
@patch('app.get_city_temperature')(因为app.py从weather_service导入了它)
- 错误:
- 过度 Mock:为了一行简单的、确定性的代码(如
math.sqrt)也去创建 Mock,这增加了测试的复杂度而无实际收益。只 Mock 你真正需要隔离的边界(I/O、外部服务、复杂依赖)。 - 忘记重置/清理 Mock 状态:虽然使用
@patch装饰器会自动处理,但在某些复杂场景(如在循环中多次 patch)下,手动管理 Mock 的状态(如调用reset_mock())很重要,以免上次测试的调用记录影响到下一次测试的断言。 - 不验证 Mock 的交互:仅仅 Mock 了一个函数并设置了返回值,但没有用
assert_called_*系列方法去验证它是否真的被以正确的方式调用过。这可能隐藏了重要的集成问题。
小结
本节课我们深入探索了软件测试中的强大武器——Mock 测试。核心要点如下:
- 目的:Mock 用于隔离被测试代码,使其与外部依赖(网络、数据库、文件系统等)解耦,从而实现快速、可靠、无副作用的单元测试。
- 核心工具:
Mock/MagicMock:创建模拟对象,用于替代真实对象。patch:上下文管理器或装饰器,用于临时替换代码中的某个对象为 Mock,是使用最广泛的工具。
- 关键实践:
- 使用
return_value设置 Mock 的返回值。 - 使用
side_effect设置 Mock 调用时引发的异常或执行其他逻辑。 - 使用
assert_called_with,assert_called_once_with等方法验证 Mock 是否被正确调用。
- 使用
- 注意事项:正确选择
patch的目标位置,避免过度 Mock,并确保测试的独立性。
掌握了 Mock 技术,你便拥有了编写高质量单元测试的关键能力,能够自信地为各种复杂的、依赖外部环境的代码编写测试。这为我们下一课学习更底层的调试工具(如 pdb)打下了坚实的基础。