Python 测试 Mock 实战——unittest.mock、pytest-mock、MagicMock 完整指南
Python 测试 Mock 实战——unittest.mock、pytest-mock、MagicMock 完整指南
适读人群:Python 开发者 / 希望写出更好的单元测试的工程师 | 阅读时长:约 16 分钟 | 核心价值:彻底掌握 Python Mock 的所有核心用法,让单元测试脱离外部依赖
那个因为没有 Mock 而调用了真实短信接口的测试
我刚参加工作时,在一个旧项目里看到这样的测试代码:
def test_register_sends_sms():
user = register_user("test@test.com", "password123", phone="13812345678")
assert user.id is not None
# 验证短信发送... 没有验证,只是 print 了一下
print("请手动查看手机是否收到短信")测试每次运行,都会真实发送一条短信。每个月他们的短信费账单很可观。
Mock 就是为了解决这类问题:让单元测试脱离真实的外部依赖(数据库、HTTP 接口、文件系统、第三方服务),用假的对象代替,让测试快速、可重复、无副作用。
unittest.mock 基础
Python 标准库内置了 unittest.mock,不需要额外安装:
from unittest.mock import Mock, MagicMock, patch, call
# 创建一个 Mock 对象
mock_service = Mock()
# Mock 对象默认接受任何调用,返回 Mock 对象
result = mock_service.some_method("arg1", "arg2")
print(type(result)) # <class 'unittest.mock.Mock'>
# 配置 Mock 的返回值
mock_service.get_user.return_value = {"id": "123", "name": "张三"}
user = mock_service.get_user("user-id")
print(user) # {'id': '123', 'name': '张三'}
# 验证 Mock 是否被调用
mock_service.get_user("user-id")
mock_service.get_user.assert_called_once_with("user-id")
mock_service.get_user.assert_called_with("user-id")
# 验证调用次数
mock_service.notify.return_value = None
mock_service.notify("user-1")
mock_service.notify("user-2")
assert mock_service.notify.call_count == 2MagicMock vs Mock
MagicMock 是 Mock 的子类,预实现了 Python 的"魔术方法"(__len__、__str__、__iter__ 等):
from unittest.mock import Mock, MagicMock
# Mock:魔术方法需要手动配置
m = Mock()
try:
len(m) # TypeError: object of type 'Mock' has no len()
except TypeError:
print("Mock 不支持 len()")
# MagicMock:自动支持魔术方法
mm = MagicMock()
mm.__len__.return_value = 5
print(len(mm)) # 5
# 模拟可迭代对象
mm.__iter__.return_value = iter([1, 2, 3])
for item in mm:
print(item) # 1, 2, 3
# 模拟上下文管理器
with mm as m:
print("进入上下文") # 正常工作使用建议: 大多数情况下用 MagicMock,它更灵活。只在明确不需要魔术方法时用 Mock。
patch 装饰器和上下文管理器
patch 是 Mock 最常用的方式——临时替换一个模块的某个属性(函数、类、对象):
from unittest.mock import patch
# 被测代码(services/notification.py)
import requests
def send_notification(user_id: str, message: str) -> bool:
response = requests.post(
"https://api.notification.com/send",
json={"user_id": user_id, "message": message}
)
return response.status_code == 200
# 测试代码
from unittest.mock import patch, MagicMock
# 方式一:装饰器
@patch("services.notification.requests.post")
def test_send_notification_success(mock_post):
mock_post.return_value.status_code = 200
result = send_notification("user-123", "Hello!")
assert result is True
mock_post.assert_called_once_with(
"https://api.notification.com/send",
json={"user_id": "user-123", "message": "Hello!"}
)
# 方式二:上下文管理器
def test_send_notification_failure():
with patch("services.notification.requests.post") as mock_post:
mock_post.return_value.status_code = 500
result = send_notification("user-123", "Hello!")
assert result is Falsepatch 路径的关键规则: 要 patch 的是被测模块中导入的名称,而不是原始定义的位置:
# 如果被测代码是:
# from datetime import datetime ← 这里把 datetime 导入到了 mymodule 命名空间
# 那么 patch 的路径是:
@patch("mymodule.datetime") # 正确:patch 被测模块里的 datetime
# 不是:
@patch("datetime.datetime") # 错误:这个位置已经被导入了,patch 无效复杂 Mock 场景
Mock 异常
@patch("services.payment.requests.post")
def test_payment_handles_network_error(mock_post):
mock_post.side_effect = requests.ConnectionError("Network unreachable")
with pytest.raises(PaymentError) as exc_info:
charge_user(user_id="user-123", amount=100)
assert "网络连接失败" in str(exc_info.value)Mock 多次调用返回不同结果
@patch("services.retry.external_api.fetch_data")
def test_retry_on_failure(mock_fetch):
# 第一次失败,第二次成功
mock_fetch.side_effect = [
requests.Timeout("Timeout"),
{"data": "success"}
]
result = fetch_data_with_retry(max_retries=3)
assert result == {"data": "success"}
assert mock_fetch.call_count == 2Mock 类和实例
@patch("services.email.EmailService") # Mock 整个类
def test_send_welcome_email(MockEmailService):
# MockEmailService 是类本身的 Mock
# MockEmailService.return_value 是实例化时的 Mock
mock_instance = MockEmailService.return_value
mock_instance.send.return_value = True
user_service = UserService()
user_service.register("new@test.com", "password")
# 验证 EmailService 被实例化了
MockEmailService.assert_called_once()
# 验证 send 方法被调用了
mock_instance.send.assert_called_once()
call_args = mock_instance.send.call_args
assert "new@test.com" in call_args[0] or \
call_args[1].get("to") == "new@test.com"pytest-mock:更 pytest 风格的 Mock
pytest-mock 把 Mock 功能包装成 fixture,更符合 pytest 的编码习惯:
pip install pytest-mockimport pytest
# mocker fixture 自动由 pytest-mock 提供
def test_send_sms_with_mocker(mocker):
# 等价于 patch(...) 装饰器,但不需要改函数签名
mock_sms_client = mocker.patch("services.sms.TwilioClient")
mock_instance = mock_sms_client.return_value
mock_instance.send_message.return_value = {"sid": "SM123", "status": "queued"}
result = send_verification_sms("13812345678", "123456")
assert result.success is True
mock_instance.send_message.assert_called_once()
# pytest-mock 在测试结束后自动恢复,不需要手动 patch.stop()
def test_database_failure_handling(mocker):
mocker.patch(
"database.session.execute",
side_effect=Exception("数据库连接超时")
)
with pytest.raises(ServiceError) as exc_info:
get_user_profile("user-123")
assert "数据库" in str(exc_info.value)spy:监控真实函数调用
mocker.spy 让你在不修改行为的情况下,监控真实函数是否被调用:
def test_cache_is_used(mocker):
spy = mocker.spy(cache_service, "get")
# 第一次调用——从数据库加载
result1 = get_user_profile("user-123")
# 第二次调用——应该从缓存加载
result2 = get_user_profile("user-123")
assert result1 == result2
# 缓存 get 被调用了 2 次
assert spy.call_count == 2
# 但数据库只查询了 1 次(第二次用了缓存)Mock 数据库操作
import pytest
from unittest.mock import MagicMock, patch
from myapp.services import UserService
def test_get_user_success():
mock_db = MagicMock()
# 模拟 db.query().filter().first() 链式调用
mock_user = MagicMock()
mock_user.id = "user-123"
mock_user.email = "test@test.com"
mock_user.is_active = True
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
service = UserService(db=mock_db)
result = service.get_active_user("user-123")
assert result.email == "test@test.com"
# 验证查询参数
mock_db.query.assert_called_once()
def test_get_user_not_found():
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
service = UserService(db=mock_db)
result = service.get_active_user("nonexistent-id")
assert result is NoneMock 时间和随机数
from unittest.mock import patch
from datetime import datetime
import time
@patch("myapp.services.datetime")
def test_token_expires_correctly(mock_datetime):
# 固定"当前时间"
fixed_time = datetime(2024, 1, 15, 10, 0, 0)
mock_datetime.now.return_value = fixed_time
mock_datetime.utcnow.return_value = fixed_time
token = create_auth_token(user_id="user-123", expires_in=3600)
# 验证 token 的过期时间是固定时间 + 3600 秒
assert token.expires_at == fixed_time.timestamp() + 3600
@patch("myapp.utils.random.randint")
def test_verification_code_generation(mock_randint):
mock_randint.return_value = 123456
code = generate_verification_code()
assert code == "123456"
mock_randint.assert_called_once_with(100000, 999999)踩坑实录
坑一:patch 路径写错,Mock 不生效
这是最常见的 Mock 问题,Mock 完全没有生效,调用了真实的函数。
诊断方法:
# 在测试中打印一下 Mock 的调用情况
@patch("myapp.services.requests.post")
def test_something(mock_post):
call_service()
print(f"mock_post 被调用了 {mock_post.call_count} 次")
# 如果输出 0 次,说明 patch 路径错了patch 路径检查清单:
# 被测文件 services/user_service.py:
from utils.email import send_email # 导入方式决定 patch 路径
# 正确的 patch 路径:
@patch("services.user_service.send_email") # 被导入到哪里,就 patch 哪里
# 错误的 patch 路径:
@patch("utils.email.send_email") # 这是原始位置,被测代码已经有了自己的引用坑二:MagicMock 链式调用每次返回不同的 Mock 对象
现象: mock.query().filter().first() 每次调用 filter() 返回的都是新的 Mock,导致断言失败。
解法: 显式配置链式调用的返回值:
mock_db = MagicMock()
# 必须显式指定每一层的 return_value
query_mock = mock_db.query.return_value
filter_mock = query_mock.filter.return_value
filter_mock.first.return_value = expected_user坑三:忘记 reset Mock,导致测试间状态污染
现象: 单独跑一个测试通过,和其他测试一起跑失败,提示 assert_called_once 但实际调用了 2 次。
原因: 如果 Mock 对象在测试间复用(比如放在 module scope 的 fixture 里),调用计数不会自动重置。
解法: 使用 mock.reset_mock() 或者把 Mock 放在 function scope 的 fixture 里:
@pytest.fixture
def mock_email_service():
with patch("services.email.EmailService") as mock:
yield mock
# patch 上下文管理器会在 yield 后自动还原,不需要手动 reset小结
Python Mock 的核心规则:
- patch 路径 = 被测模块.被导入的名称,不是原始定义位置
- MagicMock 适用大多数场景,支持魔术方法
- side_effect 用于模拟异常和多次不同返回
- pytest-mock 的 mocker 自动清理,不需要手动 patch.stop()
- 每个测试用独立的 Mock 实例,避免状态污染
