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对象 在测试期间替换模块、类或实例方法
关键点: 你不需要记住所有API,只要记住「Mock造桩,patch插桩」这八个字。

举个例子。假设我有一个发送邮件的函数,它内部调用了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一句话搞定。

我的建议: 如果你不确定Mock的对象是否会用到魔法方法,直接无脑用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的三大核心了。下图帮你把关系理一遍:

unittest.mock 核心组件关系图 unittest.mock Mock MagicMock patch 替换依赖 自动实现魔法方法 装饰器/上下文管理器 验证调用 小贴士 MagicMock自动提供 __len__、__iter__等魔法方法