Mock框架入门(一):Python unittest.mock模块基础、MagicMock与patch装饰器
写测试的时候,最烦的就是外部依赖 – 数据库连不上、第三方API关机、网络波动…… 以前我在一个金融项目里,每次跑单测都要先启动好几个容器,一跑就是二十分钟。后来同事丢给我一句话:「用Mock把依赖卡掉。」我一听就懂了,说白了就是做个假货,让被测代码以为自己正在跟真东西交互。
Python自带的unittest.mock模块就是干这个的。今天咱们先聊三个最核心的东西:Mock、MagicMock和patch。我个人习惯是先理解patch怎么替换,再回头学Mock对象的细节,这样不容易晕。好,直接开干。
1. unittest.mock 模块概览
unittest.mock是Python 3.3之后内置到标准库的,你不需要装任何第三方包。它主要提供了两个类和一个关键工具:
| 组件 | 作用 | 典型场景 |
|---|---|---|
Mock |
基础模拟对象,记录调用并能指定返回值 | 替换函数、对象、类实例 |
MagicMock |
增强版Mock,自动实现__len__、__iter__等魔法方法 |
模拟容器、上下文管理器、可迭代对象 |
patch |
装饰器/上下文管理器,临时替换目标为Mock对象 | 在测试期间替换模块、类或实例方法 |
举个例子。假设我有一个发送邮件的函数,它内部调用了smtplib.SMTP。测试时我不想真连SMTP服务器,那就用patch把smtplib.SMTP整个换成一个Mock对象。Mock对象上的所有调用都不会真的执行,只会被记录下来。
from unittest.mock import Mock, patch
import smtplib
def send_email(addr):
server = smtplib.SMTP('smtp.example.com')
server.sendmail('me@example.com', [addr], 'Hello')
server.quit()
# 测试
@patch('smtplib.SMTP')
def test_send_email(mock_smtp):
send_email('test@example.com')
# 验证SMTP被调用了,并且参数正确
mock_smtp.assert_called_once_with('smtp.example.com')
你看,测试函数多了一个参数mock_smtp – 它就是patch自动生成的Mock对象。这时候你再调send_email,SMTP那一套逻辑全被Mock接管了。我在项目里第一次这么写的时候,单测从20分钟降到5秒,开心得不行。
2. MagicMock:增强版的Mock
普通Mock有一个小毛病 – 它不支持魔法方法(比如__len__、__getitem__、__enter__)。你想想看,如果你Mock了一个列表,代码里写len(mock_obj),它不会返回任何东西,而是直接抛异常。为什么会这样?因为Mock没有实现__len__。
MagicMock解决了这个问题。它预设了几乎所有的魔法方法,每个都返回一个默认的Mock对象。比方说:
from unittest.mock import MagicMock
mm = MagicMock()
len(mm) # 返回 0(MM默认)
'x' in mm # 返回 False
iter(mm) # 返回一个空迭代器
with mm: # 正常通过
pass
我记得有一次要模拟一个文件句柄,那个类实现了__enter__和__exit__,我用普通Mock折腾了半天,后来换成MagicMock一句话搞定。
MagicMock。性能差异可以忽略,但省心不少。
不过要注意一点 – MagicMock的魔法方法默认返回的是Mock对象,不是具体的类型。比如mm.__add__()返回一个Mock,而不是数字。如果你需要特定的行为,可以直接赋值:
mm.__len__.return_value = 42
assert len(mm) == 42
3. patch装饰器:轻松替换依赖
patch是unittest.mock里我用的最多的东西。它有两种主要用法:
- 装饰器方式:作用在整个测试函数上
- 上下文管理器:作用在with语句块内
# 装饰器
@patch('os.getcwd')
def test_with_decorator(mock_getcwd):
mock_getcwd.return_value = '/fake/path'
import os
assert os.getcwd() == '/fake/path'
# 上下文管理器
def test_with_context():
with patch('os.getcwd') as mock_getcwd:
mock_getcwd.return_value = '/fake/path'
import os
assert os.getcwd() == '/fake/path'
被patch替换的路径必须是导入时的路径,而不是类定义时的路径。这个坑我踩过好几次。看下面:
# 模块 some_module.py
import requests
def call_api():
return requests.get('https://...')
# 测试时!!!必须 patch 'some_module.requests' 而不是 'requests'
@patch('some_module.requests')
def test_call_api(mock_requests):
# ...
我曾经在这里翻车翻了两小时 – 因为直接patch了全局的requests,但被测代码访问的是它自己模块里的requests,根本没替换掉。记住:patch的是「使用时所在的位置」,不是「定义时所在的位置」。
autospec=True,结果Mock出了一个不存在的方法,测试竟然通过了。后来上了生产代码崩溃了。建议每次patch时加上autospec=True,它会根据被替换对象的签名自动约束Mock的行为,防止你写错方法名。
@patch('some_module.requests', autospec=True)
def test_safe(mock_requests):
# mock_requests 会只拥有 requests 模块的属性和方法
再多说一句,patch还支持start()和stop()手动控制生命周期,适合setUp/tearDown场景:
class MyTest(unittest.TestCase):
def setUp(self):
self.patcher = patch('os.getcwd', return_value='/fake')
self.mock_getcwd = self.patcher.start()
def tearDown(self):
self.patcher.stop()
def test_path(self):
import os
self.assertEqual(os.getcwd(), '/fake')
嗯,到这里你已经掌握了Mock的三大核心了。下图帮你把关系理一遍: