在Python写单元测试,最头疼的往往不是业务逻辑本身,而是测试环境里那几个“不稳定因素”。比如某个外部函数要连数据库、调远端接口,或者依赖系统时间。这些依赖要是真的一起跑,测试就变得又慢又脆,稍微有点网络波动就挂给你看。这时候就该unittest.mock上场了。它能把外部函数暂时“调包”成假对象,让测试只在纯逻辑层面跑,既快又稳。这篇我打算用一个极简示例,演示怎么在单元测试里模拟一个外部函数,顺便说说我在实际项目里踩过的几个坑,希望对你有点帮助。
“外部函数”这个词听着抽象,其实指的就是任何不在当前模块里定义、来自第三方库或远端服务的函数。比如我们经常遇到的requests.get、os.listdir、甚至自己项目里另一个模块的通话函数。模拟的核心思路很简单:让被测代码在测试时不再去调用真实的外部实现,而是返回我们预先设好的值。这样既能隔离环境,又能精确控制返回结果,测试的可靠性自然就上来了。
这篇文章适合两类读者:一是刚开始写单元测试,整天被数据库、网络连接折磨的新手,你至少能从这里找到mock的基本用法和一套可以直接抄的示例;二是已经在用mock,但偶尔被patch路径、装饰器顺序这些细节绊住的进阶使用者。看完你大概能理解mock的常用套路,知道什么时候该用,以及怎么避开最常见的几个坑。
1. 内容整体设计与思路拆解
1.1 为什么需要在单元测试中模拟外部函数
单元测试的核心目标是验证单个函数或模块的逻辑是否正确,而不是验证整个系统的集成。换句话说,我们要测的是“这个函数输入A,会不会输出B”,而不是“整个服务连上数据库之后能不能跑通”。一旦被测代码里依赖了一个需要真实环境的外部函数,测试就变味了。
举个常见场景。假设有个process_data()函数,内部调用了fetch_user_info(),而fetch_user_info()需要访问数据库。那么每次跑单元测试,你的本机或者CI环境都得保证数据库可用,不然测试就挂了。更麻烦的是,别人开发时改了数据库里的数据,你的测试结果也跟着变,这已经完全偏离了“单元测试”的本意。
mock就是为了解决这个问题。它的价值可以总结成四点:
- 提速:不用真连网络、真查数据库,毫秒级跑完一个用例。
- 稳定:外部服务的波动和状态都不会影响测试结果,测试可重复执行。
- 精确:你让外部函数返回什么,它就返回什么,正常值、异常、超时都能随意模拟。
- 隔离:被测代码和外部实现彻底解耦,出问题的时候能更快定位是哪个模块的责任。
说到底,mock并不是什么高端技巧,它就是给测试装了一道“隔离闸门”,把不需要关心的部分挡在外面。
1.2 模拟方案的选型与取舍
Python里可用的模拟库不止一个。标准库自带unittest.mock,第三方库有pytest-mock,还有更复杂的如同responses、vcrpy这类专门针对HTTP的库。极简示例肯定选标准库,因为它是内置的,零依赖,随便哪个Python环境都能跑。unittest.mock功能已经足够完整,从创建Mock对象、patch装饰器,到自动断言调用全都覆盖了。
我见过有些人嫌unittest.mock难用,干脆自己写假函数。比如把外部函数换成lambda: 42。这种方式在极少数场景下能对付,但一旦你需要断言“被测代码到底以什么参数调用了外部函数”,自己手写就痛苦了。你还得自己维护一个调用计数器,然后再写一堆if判断。这纯粹是重复劳动,mock框架自带的assert_called_with就是干这个的。
当然,mock也不是万能的。如果你把外部函数所有行为都mock成同一个返回值,那测试用例的覆盖度就会下降,看起来测试全绿,实则很多分支根本没走到。这需要设计用例时格外注意。另外,mock会掩盖真实环境的某些问题,比如网络超时后重试逻辑是否健全,这类问题靠mock单测发现不了,还得靠集成测试兜底。
2. 核心细节解析与实操要点
2.1 Mock对象与MagicMock的区别
刚开始用mock的时候,我老搞不清楚Mock和MagicMock到底该用哪个。官方文档里说,MagicMock是Mock的子类,它额外支持魔术方法,比如__iter__、__len__、__getitem__。如果你的被测代码里对外部函数返回值做了迭代、取长度、切片这类操作,那MagicMock更合适,因为它能把这些魔术方法也mock出来。
举个例子。假设外部函数返回一个列表,你的代码要对它用len()。如果用普通Mock,len(mock)会直接报TypeError,因为Mock没有实现__len__。但MagicMock就支持__len__,默认返回0或你设的值。所以那种“啥都能模拟”的需求,用MagicMock准没错。
不过MagicMock也会带来一个副作用:它太“全能”了,连你没想到的调用它都会自动返回一个新Mock。这会让一些拼写错误悄悄溜过去。所以我的建议是,能用Mock就用Mock,需要支持魔术方法了再切到MagicMock。虽然这事儿不算大,但它关系到测试会不会出现“假绿”。
2.2 patch的三种标准用法
patch是mock里的核心入口,它一共有三种常见用法:装饰器、上下文管理器、手动start/stop。三种用法本质一样,都是在指定作用域内把目标名字替换成Mock对象,退出时恢复原样。
装饰器用法适合整个测试函数都处于mock环境下:
@mock.patch("my_module.external_func") def test_something(mock_external): mock_external.return_value = 10 # ...上下文管理器适合只在代码的一小段需要mock时使用:
def test_something(): with mock.patch("my_module.external_func") as mock_external: mock_external.return_value = 10 # ...手动start/stop的用法最灵活,一般用在setUp和tearDown里,或者在很复杂的测试场景里精细控制生命周期。说实话,我在日常开发中用的最多的是上下文管理器,其次才是装饰器。装饰器的好处是简洁,缺点是一旦patch的路径太长,多层嵌套起来可读性就会变差。手动start/stop用得最少,因为它要求你在tearDown里确保stop,不然会污染其他测试。
2.3 返回值、副作用与断言的基本套路
mock最核心的能力就是控制“外部函数”的响应。两个关键词:return_value和side_effect。
return_value:用来设置正常情况下的返回值。可以是一个值,也可以是一个Mock对象。side_effect:用来设置异常或者动态返回值。比如给side_effect赋一个异常类,当mock被调用时就会抛出这个异常。也可以赋一个函数,每次调用时动态计算返回值。
断言方面,常用的一共有这几种:
assert_called():判断调用过。assert_called_once():判断只调用过一次。assert_called_with(...):判断最近一次调用的参数。assert_called_once_with(...):判断只调用过一次且参数匹配。
除此之外,mock.call_count可以直接读取调用次数。这些断言能让你在测试里把“被测代码是否按预期调用了外部函数”这件事查得清清楚楚。
3. 实操过程与核心环节实现
3.1 一个完整的极简示例
我们就模拟一个常见的真实场景:被测函数要从远端接口获取天气信息,然后返回温度。外部函数就选requests.get。
被测代码写在weather.py里:
import requests def get_weather(city): url = f"https://api.example.com/weather?city={city}" response = requests.get(url) data = response.json() return data.get("temperature")测试代码写在test_weather.py里:
from unittest import mock from weather import get_weather @mock.patch("weather.requests.get") def test_get_weather(mock_get): # 创建一个Mock作为response mock_response = mock.Mock() mock_response.json.return_value = {"temperature": 25} mock_get.return_value = mock_response result = get_weather("北京") assert result == 25 mock_get.assert_called_once_with( "https://api.example.com/weather?city=北京" )这段代码虽然只有十来行,但把mock的三大要点都覆盖了:patch到正确的路径、设置返回值、利用断言验证调用。这里最需要留神的是patch的路径。为什么是weather.requests.get而不是requests.get?因为被测代码是在weather模块里通过import requests拿到requests的,所以调用发生时,Python是在weather模块的命名空间里查找requests。patch那里必须是“名字被使用的地方”,不是“名字被定义的地方”。这个区别我反复踩过,很多新手朋友的mock不生效,八成都是卡在这个地方。
3.2 两种设置返回值的方式对比
回到刚才的示例,你可能注意到我创建了mock_response,然后又给它设了json.return_value。这里其实还有另一种等价写法,直接用MagicMock:
@mock.patch("weather.requests.get") def test_get_weather(mock_get): mock_get.return_value.json.return_value = {"temperature": 25} result = get_weather("北京") assert result == 25这种写法更紧凑,因为mock_get.return_value本身就是一个Mock对象,你可以直接给它继续设置属性。前提是这个Mock对象能够支持json()方法,而普通Mock也支持,因为它对任何方法调用都会返回Mock。不过要注意,这种连写真香,但一旦链条变长,出错的定位成本也会增加。我更推荐在业务逻辑稍复杂时,先显式创建mock_response,代码可读性更高。
3.3 如何用side_effect模拟异常和动态返回值
光有正常返回值还不够,测试里往往要覆盖异常路径。继续用上面那个天气函数,如果requests.get抛了超时异常,你的被测函数反应是否正常?
@mock.patch("weather.requests.get") def test_get_weather_timeout(mock_get): mock_get.side_effect = requests.exceptions.Timeout try: get_weather("南京") except requests.exceptions.Timeout: assert True else: assert False这种写法直接抛异常,适合验证被测函数是否透传异常。但更好的实践是被测函数自己处理异常,比如超时就返回默认值。那样测试里只要让mock抛异常,然后断言返回默认值就行了。
除了抛异常,side_effect还可以指向一个函数。比如外部函数的返回值依赖于输入参数时,你可以写:
def fake_get(url): if "city=北京" in url: return {"temperature": 25} return {"temperature": 30} mock_get.side_effect = fake_get这样每次调用都会走你定义的逻辑,就相当于一个“迷你假服务”。在写复杂集成逻辑的测试时,这种方式特别有用。
3.4 多个外部函数同时mock的正确姿势
实际业务里,一个函数同时依赖两三个外部函数太常见了。如果要同时patch多个,可以用多个装饰器嵌套,但这里有个让人抓狂的细节:装饰器参数顺序和patch顺序是反的。
@mock.patch("weather.requests.get") @mock.patch("weather.log_info") def test_both(mock_log, mock_get): # 最靠近函数的参数对应最下方的patch ...看到没,参数顺序是mock_log, mock_get,和装饰器的上下顺序是反的。这个坑我栽过好几次,每次都要想半天谁对应谁。我现在的对策是:能少用装饰器就少用,改成上下文管理器嵌套,逻辑更清晰:
def test_both(): with mock.patch("weather.requests.get") as mock_get: with mock.patch("weather.log_info") as mock_log: ...虽然缩进多了两层,但谁是谁一目了然,也永远不会错序。对新手来说,我建议直接用上下文管理器,先跑通,再研究装饰器。
3.5 如何验证mock是不是真的生效了
有时候你写完了测试,跑起来全绿,但心里没底。这时候可以主动验证一下mock到底被没被调用。最直接的方式就是写断言:mock_get.assert_called()。如果被测代码压根没走这条路,断言就会直接失败。再精细一点,可以打印mock_get.call_args_list,看它记录了哪些调用参数。我在排查问题时,经常靠call_args_list来反推被测代码的执行路径。
还有一种更隐蔽的情况:mock被使用了,但使用的是默认行为,返回了一个Mock对象而不是你想要的值。这时候被测函数可能因为类型错误而抛异常。如果你看到类似“TypeError: 'Mock' object is not iterable”这样的报错,基本就是忘了设置return_value或者链式调用的某个环节没设置。
4. 常见问题与排查技巧实录
4.1 patch路径写错,mock静默失效
这是全公司Python开发者都会踩的坑。记住那句话:patch的目标是“名字被在哪里使用”,不是“名字在哪里定义”。你测试的文件里写了from weather import get_weather,但被测文件weather.py内部写的是import requests。这时要patch的是weather.requests.get,不是requests.get。如果你傻乎乎地去patch了全局的requests.get,首先它会影响其他测试,其次因为weather模块里的requests指向的是同一个模块对象,其实有时也能生效,但很容易造成混乱。正确的做法永远是尽量精确到模块路径。
4.2 Mock返回值忘设置,测试出现“假绿”或类型错误
最常见的“假绿”场景是:你断言了一个返回值,但被测代码压根没走到你mock的地方,而是走了另一个分支。表面看测试过了,实际上测了个寂寞。所以写完mock测试,我习惯先故意把return_value改错,确认测试会红,再改回来。这一步虽然有点“自虐”,但能有效避免假绿。
另外就是忘了设置返回值导致类型错误。记住,Mock对象的任何方法调用都会返回新的Mock,如果你的外部函数本应返回整数,但你在测试里没设return_value,那被测代码拿到的就是Mock对象,一做起算术运算立刻报错。看到“unsupported operand type(s) for +: 'Mock' and 'int'”你就明白了。
4.3 patch嵌套顺序容易搞反
前面提过,多个装饰器的参数顺序和装饰器顺序相反。这个我只能反复强调:每次换新项目、新同事来写测试,这里都会闹笑话。我的建议是,如果你实在要用装饰器嵌套,就在第一个patch的装饰器下面写一行注释,标明参数对应关系。不然过两周连你自己都忘了哪个是哪个。
4.4 Mock的“自动造属性”掩盖真实bug
Mock对象有个特性:你访问任何不存在的属性,它都会自动返回一个新Mock。这就意味着,如果你被测代码里把response.json()写成了response.json(少括号),mock不会报错,它只会返回一个Mock对象,后续操作可能莫名其妙“成功”然后干别的事情去了。这种错误在真实环境肯定立刻暴露,但在mock测试里却会被悄悄吞掉。所以我建议在关键节点用assert_called_with把调用参数固定下来,一旦多了这种拼写错误,断言立刻失败。
4.5 性能与隔离的平衡建议
mock能隔离环境,但也容易掩盖性能问题。就拿查数据库来说,你mock了返回值,被测代码内部对结果集做了双层循环,这个O(n^2)的问题在mock测试里根本看不出来。如果你们项目的单元测试全是用mock写的,建议定期跑一遍真实集成测试,或者至少对热点路径留几个不mock的慢测试,用来感知性能变化。
4.6 mock之外的选择:何时不该用它
有些情况下不该用mock。比如你测试的是外部函数本身的行为,那当然不能mock;又比如你的被测代码逻辑非常简单,纯函数,不依赖外部,那完全不需要mock。还有,如果外部函数的实现非常轻量,比如一个纯计算函数,那真跑也很快,mock反而增加复杂度。我见过有人把random.randint也mock掉,导致随机性测试失去意义,那就有点过度设计了。mock是一条好用的捷径,但别把它当成万能钥匙。
我个人实际操作中的体会是,mock的价值不在“替代”,而在“隔离”。你没有必要把所有外部依赖都mock掉,只mock那些让你的测试变慢、变脆的部分就够了。极简示例的题眼是“极简”,但背后是对测试稳定性的追求。下次写测试的时候,别急着把所有外部调用都塞进mock,先想清楚你到底要验证什么逻辑,再决定怎么patch。这样写出来的测试,才既快又有意义。