小孙那天给一个外部API客户端写单元测试。代码大概长这样:
# api_client.py
import requests
def fetch_user(user_id):
resp = requests.get(f"https://api.example.com/users/{user_id}")
resp.raise_for_status()
return resp.json()
测试的目标是:验证fetch_user能正确解析响应,而不真的去发网络请求。小孙写了这样一个测试:
# test_api_client.py
from unittest.mock import Mock
from api_client import fetch_user
def test_fetch_user():
mock_response = Mock()
mock_response.json.return_value = {"id": 1, "name": "Alice"}
user = fetch_user(1)
assert user["name"] == "Alice"
他跑了一下,测试确实通过了。但他打印了一下user,发现里面是真实的API返回数据——网络请求真的发出去了。他盯着代码看了半天:“我明明创建了mock_response,为什么fetch_user没用到它?”
问题出在他只是创建了一个Mock对象,但没有把它替换到requests.get的位置上。fetch_user里调用的是requests.get,而小孙的mock_response变量从来没有跟requests.get建立任何关系。
他真正需要的是patch:在测试期间,把requests.get临时替换成返回mock_response的假函数。
“我以为创建了Mock,代码就会自动用它。原来Mock只是一个假对象,patch才是把假对象塞进去的动作。”
这个误解,是很多人刚接触unittest.mock时最常踩的坑。Mock和patch不是“两种mock方式”,它们是两个不同层面的东西:一个造零件,一个换零件。
先看一个最直观的对比
from unittest.mock import Mock, patch
# Mock:创建一个假对象
fake = Mock()
fake.get.return_value = "假数据"
print(fake.get()) # "假数据"
# patch:临时替换真实对象
with patch("requests.get") as mock_get:
mock_get.return_value = "假数据"
import requests
print(requests.get("http://example.com")) # "假数据"
# 出了 with 块,requests.get 恢复原样
print(requests.get) # <function get at ...>,真实的函数
Mock是一个类,你实例化它,得到一个假对象。这个假对象可以响应任何属性访问、任何方法调用,返回你设定的值。但它只存在于你自己的变量里,不会影响任何真实代码。
patch是一个函数(同时也是装饰器和上下文管理器),它做一件事:在指定范围内,把某个真实对象替换成Mock,离开范围后恢复原样。
用一个生活场景来理解。
Mock是造了一把假钥匙。这把钥匙能开锁,但它只在你手里,门上的锁还是原来那把。
patch是趁没人的时候,把门上的锁临时换掉。进去拿完东西,再把原来的锁装回去。
区别一目了然:Mock造工具,patch换环境。
Mock:万能假对象
Mock的核心能力是“你说什么它都答应”。
from unittest.mock import Mock
m = Mock()
# 调用它
m() # 返回一个 Mock 对象
# 访问属性
m.name # 返回一个 Mock 对象
m.age # 也返回一个 Mock 对象
# 调用方法
m.do_something(1, 2) # 返回一个 Mock 对象
# 设置返回值
m.get.return_value = 42
print(m.get()) # 42
Mock默认会为任何属性访问创建一个子Mock。这是它的“万能”之处,也是它的“坑”之一:如果代码里有个拼写错误,Mock不会报错,它会默默返回一个新的Mock,测试可能照样通过,但实际代码是错的。
m = Mock()
m.get_user.return_value = {"name": "Alice"}
print(m.get_usr()) # 拼错了,但返回一个 Mock,不报错
Mock适合用来替代那些你完全不需要真实行为的协作者:外部API、数据库连接、文件系统、第三方服务。你只需要它“假装在那里”,并且按照你的设定返回数据。
patch:临时替换,离开恢复
patch的核心能力是“在指定范围内偷梁换柱”。
from unittest.mock import patch
import requests
with patch("requests.get") as mock_get:
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"name": "Alice"}
resp = requests.get("http://example.com")
print(resp.json()) # {"name": "Alice"}
# 出了 with 块,requests.get 恢复成真实函数
patch的三种用法:
上下文管理器:with patch(...) as mock_obj:,适合单个测试里的临时替换。
装饰器:@patch("module.attr"),适合整个测试函数或测试方法。
手动启动/停止:p = patch(...); p.start(); ...; p.stop(),适合需要在setUp/tearDown里控制的场景。
无论哪种用法,patch的语义都是一样的:进入时替换,离开时恢复。它保证测试结束后,全局状态不会留下痕迹。
坑一:patch 的路径写错
这是patch最容易出错的地方,也是小孙最初没搞明白的地方。
patch的参数是“你要替换的那个名字在哪个模块里”,不是“这个东西定义在哪里”。
# api_client.py
import requests
def fetch_user(user_id):
resp = requests.get(...)
这里api_client模块导入了requests,所以api_client的命名空间里有一个名字叫requests,指向requests模块。fetch_user调用的是api_client.requests.get。
所以patch的正确路径是:
@patch("api_client.requests.get") # 替换 api_client 模块里的 requests.get
def test_fetch_user(mock_get):
...
而不是:
@patch("requests.get") # 替换的是 requests 模块里的 get,可能不影响 api_client
为什么后者“可能”不影响?因为api_client在导入时,requests这个名字已经绑定到了requests模块对象上。patch("requests.get")确实替换了requests模块的get属性,如果api_client是通过requests.get访问的,理论上能看到替换。但这里有一个关键细节:patch替换的是当前时刻requests模块的get属性,而api_client里的requests指向同一个模块对象,所以api_client.requests.get也会看到替换。
那为什么还推荐patch("api_client.requests.get")?因为**如果代码用的是from requests import get**,情况就完全不同了:
# api_client.py
from requests import get
def fetch_user(user_id):
resp = get(...) # 用的是 api_client 命名空间里的 get
这时候patch("requests.get")无效,因为api_client里的get是导入时复制过来的独立引用,和requests.get已经没关系了。必须用patch("api_client.get")。
规则:patch 的目标是“被测试代码查找那个名字的地方”,不是“那个东西最初定义的地方”。 这是patch最核心的规则,也是最容易记错的一条。
坑二:以为 Mock() 会自动替换
mock_get = Mock()
mock_get.return_value.json.return_value = {"name": "Alice"}
# 以为 fetch_user 会用 mock_get,实际上不会
user = fetch_user(1)
Mock()只是创建了一个对象,赋值给变量mock_get。fetch_user内部调用的是requests.get,和mock_get没有任何关系。要让fetch_user用上这个Mock,必须用patch把它放到requests.get的位置上。
with patch("api_client.requests.get", return_value=mock_get.return_value):
user = fetch_user(1)
或者更常见的写法:
with patch("api_client.requests.get") as mock_get:
mock_get.return_value.json.return_value = {"name": "Alice"}
user = fetch_user(1)
patch返回的mock_get就是替换上去的那个Mock对象。你在这个对象上设返回值,被测代码调用requests.get时拿到的就是你的设定。
坑三:patch 装饰器的参数顺序
用@patch装饰器时,Mock对象会作为参数传给测试函数。如果有多个@patch,参数的顺序是从下往上对应的。
@patch("module.func_b")
@patch("module.func_a")
def test_something(mock_a, mock_b):
...
最下面的@patch("module.func_a")对应第一个参数mock_a,上面的@patch("module.func_b")对应第二个参数mock_b。
这个顺序很容易记反。写多个@patch时,最稳妥的方式是给每个Mock起个明确的名字,然后对照装饰器的顺序检查一遍。
坑四:patch 了错误的层级
# service.py
import database
def get_user(user_id):
return database.query(f"SELECT * FROM users WHERE id = {user_id}")
要测试get_user,需要patch database.query。但如果你写:
@patch("service.database.query")
def test_get_user(mock_query):
...
这是对的,因为service模块导入了database,service.database指向database模块,service.database.query就是database.query。
但如果你写:
@patch("database.query")
def test_get_user(mock_query):
...
也可能对,取决于service是怎么导入的。如果service是import database,那么service.database和database是同一个模块对象,patch任何一个都能生效。但如果service是from database import query,那么service.query是一个独立的引用,必须patch service.query。
最安全的做法:patch service.database.query,因为这是被测代码实际查找的路径。即使service用的是from database import query,patch("service.database.query")也能生效(如果service.database还存在的话),但更准确的写法是patch("service.query")。
规则还是那一条:patch 被测代码查找名字的位置。
坑五:忘记 autospec,Mock 太“宽容”
@patch("api_client.requests.get")
def test_fetch_user(mock_get):
mock_get.return_value.json.return_value = {"name": "Alice"}
...
这个测试能跑,但mock_get没有spec,它可以接受任何参数、返回任何东西。如果被测代码调用requests.get时传错了参数,测试不会发现。
# 被测代码写错了,传了错误的关键字参数
resp = requests.get(url, timout=10) # 拼写错误,应该是 timeout
# 测试照样通过,因为 mock_get 接受任何参数
autospec=True会让Mock根据真实函数的签名来限制参数:
@patch("api_client.requests.get", autospec=True)
def test_fetch_user(mock_get):
...
这样mock_get就只能接受requests.get真实支持的参数,传错参数会直接报TypeError,测试能发现这类错误。
autospec的代价是配置稍微麻烦一点,但它能抓住“参数拼写错误”“参数数量错误”这类Mock默认会放过的bug。
坑六:patch 没有清理,污染其他测试
用patch的start()/stop()手动控制时,如果stop()没被调用(比如测试中途抛异常),替换会一直生效,影响后续测试。
def test_something():
p = patch("module.func")
mock_func = p.start()
# 如果这里抛异常,p.stop() 不会执行
do_something()
p.stop()
解决方案是用try/finally,或者直接用with/装饰器,它们会自动清理。
def test_something():
with patch("module.func") as mock_func:
do_something()
# 自动 stop
with和装饰器在异常时也会执行清理,这是它们比手动start()/stop()更安全的地方。
坑七:patch 内置函数或模块属性
patch可以替换很多东西,但不是所有东西都能替换。
能替换的:模块里的函数、类、类的方法、模块属性、sys.modules里的模块。
不能替换或很难替换的:内置函数(如len、print)、C扩展里的函数、不可变对象的属性。
# 替换内置函数,通常不推荐
@patch("builtins.print")
def test_something(mock_print):
...
替换内置函数会影响整个进程,包括测试框架自己的输出。除非确实需要,否则不要patch内置函数。
什么时候用 Mock,什么时候用 patch?
判断标准很清晰:
需要一个假对象,作为参数传给被测代码:用Mock()。
def test_process():
fake_db = Mock()
fake_db.query.return_value = [{"id": 1}]
result = process(fake_db) # 把假对象传进去
assert result == ...
需要替换被测代码内部引用的某个真实对象:用patch()。
@patch("service.database.query")
def test_get_user(mock_query):
mock_query.return_value = [{"id": 1}]
result = get_user(1) # get_user 内部调用 database.query
assert result == ...
两者结合:用patch替换,用Mock作为返回值或副作用。
@patch("service.database")
def test_get_user(mock_db):
mock_db.query.return_value = [{"id": 1}]
...
Mock是“造一个假对象”,patch是“把假对象放到真实代码会去找的位置上”。两者解决的是不同的问题,经常一起用,但不能互相替代。
回到小孙的故事
小孙最终的测试是这样的:
from unittest.mock import patch
from api_client import fetch_user
@patch("api_client.requests.get", autospec=True)
def test_fetch_user(mock_get):
mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"}
user = fetch_user(1)
assert user["name"] == "Alice"
mock_get.assert_called_once_with("https://api.example.com/users/1")
patch把api_client.requests.get替换成了Mock,fetch_user调用时拿到的是假响应,不会发真实网络请求。autospec=True保证调用参数和真实requests.get的签名一致。assert_called_once_with验证了调用参数正确。
他后来在团队文档里写了一条:“Mock是造一个假对象,不会自动替换任何东西。patch是把假对象放到真实代码查找的位置上,离开时恢复。patch的路径写‘被测代码查找名字的地方’,不是‘东西定义的地方’。多个@patch参数从下往上对应。尽量加autospec=True,让Mock的签名和真实函数一致。”
这个区分看起来简单,但真到写代码的时候,很多人会创建了Mock却忘了patch,然后发现网络请求真的发出去了;或者patch了错误的路径,测试通过了但实际代码没被替换;或者patch了requests.get但代码用的是from requests import get,替换根本不生效。
记住那个换锁的比喻:Mock是造一把假钥匙,patch是趁没人把门上的锁临时换掉。你要让假钥匙起作用,得先把锁换了。换完记得换回来,否则别人用原来的钥匙就打不开门了。
Mock和patch本身不难,难的是想清楚“被测代码到底在哪个命名空间里查找这个名字”。想清楚了,patch的路径就不会写错,Mock也不会白造。