尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Mock与Spy的区别:测试替身在单元测试中的正确用法与避坑指南

Mock与Spy的区别:测试替身在单元测试中的正确用法与避坑指南 写测试写了这些年我越来越觉得测试圈有个挺黑色幽默的现象很多测试代码跑得飞快、绿得发亮可真到代码重构、线上出bug的时候它一点忙都帮不上。最讽刺的是这类测试往往用了大量Mock看起来把依赖隔离得干干净净实际上测的根本不是你的业务逻辑而是Mock自身的“剧本”。同样的问题也出在Spy上——很多人分不清Mock和Spy的区别以为都是“替身”结果在该保留真实逻辑的地方硬造了一个假对象在该隔离的地方又让真实依赖偷偷跑了进去。这篇文章想把Mock和Spy这两兄弟彻底摊开来讲清楚它们各自是什么、本质差别在哪里、什么时候该用谁、怎么用才不会让测试变成自欺欺人。内容会围绕单元测试和集成测试中“隔离依赖”这个核心场景展开最后还会分享一些我在代码审查和实际排障中总结出来的避坑经验。不管你是刚接触测试的新手还是已经写了一两年测试但总觉得哪里不对劲的同学这篇文章应该都能帮上点忙。1. 先搞清楚Mock、Stub、Spy这三兄弟到底谁是谁在聊Mock和Spy之前很有必要先拉一个第三方进来——Stub。很多人把Stub、Mock、Spy混在一起叫“打桩”但它们在测试里的角色其实差别非常大用错了会直接影响测试方向。我不打算讲太学院派的概念就说人话。1.1 三个角色各自的“人格画像”Stub替身演员它的任务只有一个——代替真实依赖返回你预设好的答案。比如你的代码里有一个获取用户信息的接口测试时你不想连数据库就写一个假的UserRepository调用它永远返回一个固定的User对象。Stub只知道“回答问题”它不关心这个接口被调了几次、传了什么参数。它的核心价值是“隔离控场”。Mock替身演员行为记录仪Mock在Stub的基础上多干了一件事——它会把所有调用行为都记录下来。什么时候被调的、传了什么参数、调了几次Mock全都记账。所以Mock不但能“回答问题”还能在最后断言“我的代码确实用正确的方式调用了这个依赖”。它的核心价值是“验证行为”而不只是“隔离”。Spy真身旁边的行车记录仪Spy和Mock最大的不同在于它会调用真实对象的真实方法也就是真实逻辑会真正执行一遍。Spy只是在真实对象外面套了一层把每次调用的参数、次数、返回值都记录下来。你不去改它的行为它就老老实实跑真逻辑你想拦截某个方法时它也支持局部替换。打个比方Stub是你请的替身演员直接替代主角上场Mock是替身演员身上挂了台摄像机既替代主角上场又记录每个动作Spy则是主角本人上场演戏但旁边有台行车记录仪全程录下主角的一举一动。理解了这三个角色后面很多纠结就不存在了。1.2 Mock与Spy的本质差异为了让你看明白我用一张表把Mock和Spy的关键差异列出来对比维度MockSpy创建方式从零创建一个假对象包装一个真实存在的对象是否执行真实逻辑默认不执行完全由你预设行为默认执行真实逻辑默认返回值基本类型返回零值对象返回空什么都不做执行完真实方法后返回真实结果行为记录记录所有调用、参数、次数、顺序同样记录所有调用、参数、次数、顺序拦截行为可以对指定方法单独做Stub可以对指定方法单独做Stub其余方法保持真实适用场景隔离外部依赖验证交互行为保留真实逻辑同时观察调用情况适合部分替代典型风险过度Mock导致测的是假的真实逻辑执行可能引入环境依赖这里最关键的一句话是Mock默认不让真实代码跑Spy默认让真实代码跑。很多人在代码里用Mock去替换一个本该走真实逻辑的对象结果真实代码的bug根本没机会暴露出来反过来也有人把Spy用在需要完全隔离的场景里结果测试还是偷偷连了数据库跑一次慢一次偶尔还挂。这个概念搞清楚之后我们才能继续聊“到底什么时候该用Mock、什么时候该用Spy”。2. 不要看到依赖就Mock决策逻辑比工具本身重要得多很多人写测试有一个本能反应只要碰到依赖立刻Mock。这个本能反应在90%的情况下是安全的但也是“自欺欺人”的最常见根源。因为不是所有依赖都应该被Mock有些依赖如果Mock掉你的测试就彻底失去了意义。2.1 先区分“外部依赖”和“内部协作者”我在Code Review里最喜欢问一个问题“你Mock掉的东西是你们团队能掌控的代码吗”这是判断是否可以Mock的第一道分界线。外部依赖数据库、Redis、消息队列、第三方支付接口、外部通知服务、HTTP客户端等。这些对象的共同特点是要么需要真实网络、真实IO要么运行环境极其复杂要么是别人维护的服务。在单测阶段我们必须用Mock或Stub把它们隔离掉否则测试就没法稳定、快速、独立地运行。这是Mock最正统的应用场景。内部协作者同一个项目里你自己写的类/模块比如领域服务、工具类、业务对象。这些代码的bug本应在测试中被发现。如果你把这些内部对象也Mock掉那测试执行时真正运行的只有被测对象的外壳内部协作逻辑全被“架空”了。一旦内部协作者有bug你的测试照样全绿这就是名副其实的“自欺欺人”。举个很常见的例子一个OrderService里调用了PriceCalculator计算价格很多同学图省事直接Mock掉PriceCalculator让calculate_price返回一个固定值。结果PriceCalculator内部如果有严重的算法bug你的测试永远发现不了。因为OrderService的真实逻辑根本没和PriceCalculator的真实逻辑发生交互。那内部协作者怎么测答案很简单直接new出来让它跑真实逻辑。如果你担心PriceCalculator还有它自己的依赖那你就去测PriceCalculator时再隔离它的依赖。一层一层地来而不是一次性把所有东西都Mock掉。2.2 四条判断规则帮你快速决定用Mock还是Spy光说概念有点虚我总结了几条非常实操的判断规则你以后遇到测试代码时可以直接套用规则一看被测对象是“状态”还是“行为”如果你的测试重点是“调用之后对象内部状态对不对”用Stub就够了不需要Mock如果你的测试重点是“这个方法有没有用正确的方式和依赖打交道”那就需要Mock或Spy。状态验证靠返回值行为验证靠Mock/Spy的断言。规则二看依赖是否涉及真实IO、真实网络凡是涉及数据库、网络、文件系统、系统时间的一律考虑Mock或Stub隔离至少也要用Spy包一层控制副作用。真实环境里跑一遍不是不行但那应该留给集成测试而不是单元测试。规则三看验证重心是“结果”还是“过程”比如测试一个下单流程你关心的是订单落库后状态为“已创建”这叫结果你关心的是支付网关被调用了、参数金额正确这叫过程。测结果用真实逻辑Stub周边依赖测过程就上Mock或Spy做行为验证。规则四看对象复杂度如果依赖对象很复杂构造起来要十几层而且它的真实逻辑和本轮测试无关那就用Mock如果对象构造简单、逻辑清晰且本身正是本轮测试要覆盖的关键路径那就用Spy保留真实逻辑。2.3 Spy的真正主战场集成测试与遗留代码Spy的定位比较特殊它最适合三类场景场景一测试真实对象周边行为。比如你写了一个带缓存的函数想确认“第二次调用时确实命中了缓存”或者“日志系统确实记录了某条关键日志”。这时你用Spy包住真实对象既让真实计算逻辑跑完又能断言调用次数非常合适。场景二第三方SDK的薄封装。你项目里可能封装了一个外部SDK封装层不能Mock否则测不了封装逻辑底层SDK调用又不想真的发网络请求。这时候可以用Spy包装底层SDK让它的大部分方法都走真实逻辑只把真正发网络请求的那一个方法替换掉。场景三遗留代码重构前的保护网。老项目没有测试直接重构风险极大。我的做法是先用Spy包住核心对象记录它当前的真实行为写出一批“行为快照型”测试。这样至少能确保重构前后行为不变。等重构完成、代码结构清晰了再把这些基于Spy的测试逐步替换成更精准的Mock真实逻辑组合。3. 实战演示用pytestmock把Mock和Spy用得明明白白理论说再多不如亲手写一段。下面我以Python生态为例用一个非常典型的“订单服务”业务场景完整演示Mock和Spy的正确用法。这套思路同样适用于Java的Mockito/MockK、JavaScript的Sinon/ViTest核心思想完全一致。3.1 准备一个真实的业务场景假设我们有一个订单服务OrderService它有三段核心协作逻辑class OrderService: def __init__(self, payment_gateway, inventory_client, mailer): self.payment_gateway payment_gateway self.inventory_client inventory_client self.mailer mailer def place_order(self, items: list, customer_email: str) - str: # 1. 校验库存 for item in items: if not self.inventory_client.check_stock(item[sku], item[quantity]): raise ValueError(f库存不足: {item[sku]}) # 2. 计算总价 total sum(item[price] * item[quantity] for item in items) # 3. 调用支付网关 order_id self.payment_gateway.charge(total) # 4. 发送发票邮箱 self.mailer.send_invoice(customer_email, order_id, items, total) return order_id这个场景很典型payment_gateway是第三方支付网关外部依赖必须Mockinventory_client是库存服务通常是另一个服务也是外部依赖建议Mockmailer是邮件服务外部依赖建议Mock。在这段代码里真正属于OrderService自己要测的逻辑是“库存校验失败就抛异常”“计算总价”“按顺序调用支付和发邮件”。所以我们Mock掉三个外部依赖把重点放在OrderService自身的选择和编排逻辑上。3.2 Mock的正确打开方式隔离依赖验证行为我用pytest来写这个单元测试pytest中可以直接用unittest.mock也可以用pytest-mock插件提供的mockerfixture更简洁。import pytest def test_place_order_should_charge_and_send_invoice(mocker): # 1. 创建被测服务和Mock对象 payment_gateway mocker.Mock() inventory_client mocker.Mock() mailer mocker.Mock() # 2. Mock行为预设 inventory_client.check_stock.return_value True payment_gateway.charge.return_value order_001 # 3. 注入Mock创建真实OrderService service OrderService( payment_gatewaypayment_gateway, inventory_clientinventory_client, mailermailer ) # 4. 执行真实方法 items [ {sku: apple, quantity: 2, price: 5.0}, {sku: banana, quantity: 1, price: 3.0}, ] result service.place_order(items, userexample.com) # 5. 断言结果和行为 assert result order_001 payment_gateway.charge.assert_called_once_with(13.0) mailer.send_invoice.assert_called_once_with( userexample.com, order_001, items, 13.0 )这个测试里我Mock了三个外部依赖但被测对象OrderService是真实的。这样测试的确定性非常强不管真实支付网关挂不挂测试都能快速跑完不管外部库存服务返回什么测试都能控制数据稳定复现“库存充足”和“库存不足”两条路径。这里有一个细节值得单独说assert_called_once_with(13.0)。我只断言了charge被调用且参数是13.0没有断言它被调了三次或者一次都不调。为什么因为如果OrderService的支付逻辑改了比如改成拆单支付那断言“一次只收总价”就会失败——这其实是好事说明测试真正跟上了业务变化。如果我只断言“charge被调用过”而不检查参数那即使金额算错、把13算成130测试照样绿那就白写了。3.3 过度Mock的反面教材测了个寂寞我们再来看一段反面代码。假设有同事这样写测试def test_place_order_over_mocked(mocker): # 错误示范把OrderService内部依赖全部Mock掉然后Mock了OrderService自己 service mocker.Mock(specOrderService) inventory_client mocker.Mock() service.place_order.return_value order_001 result service.place_order([], xy.com) assert result order_001这个测试的问题一眼就能看出来place_order完全是Mock出来的真实方法体一行都没执行。它测的不是OrderService的下单逻辑而是Mock对象返回了一个预设字符串。这种测试在工程里并不少见尤其是当被测对象构造复杂、依赖依赖很深时很多人图省事直接“Mock掉整个被测类”。更要命的还有一种“Mock污染”场景把inventory_client的check_stock设置为return_valueTrue但完全没意识到库存逻辑写在OrderService里而OrderService里判断的是if not check_stock: raise。如果同事顺手把return_value设成了False那测试就跑挂了但这不是业务错而是Mock配置错了——测试完全没有验证业务逻辑。所以写测试时可以给自己立一条规矩被测对象SUTSystem Under Test本身永远不要Mock。可以Mock它的依赖可以Mock外部服务但被测对象的真实代码必须完整执行。否则你就是在测Mock不是在测代码。3.4 Spy的正确打开方式保留真实逻辑记录调用细节Spy的核心优势在于“动真格但留痕迹”。最典型的场景是我们有一个库存计算器或者定时任务真实逻辑需要完整执行但我们还想确认它确实发出了某个通知。这时用mocker.spy最合适。import time class DeliveryEstimator: 一个简单的配送时长估算器内部含sleep模拟耗时计算。 def estimate(self, distance_km: float) - float: # 真实逻辑根据距离计算时长 time.sleep(0.01) if distance_km 5: return 1.0 if distance_km 20: return 2.5 return 5.0 def test_estimator_returns_real_value_and_logs_call(mocker): estimator DeliveryEstimator() spy mocker.spy(estimator, estimate) result estimator.estimate(10) # 真实方法被执行返回真实值 assert result 2.5 # 同时Spy记录了调用参数和次数 spy.assert_called_once_with(10)这个测试里estimate(10)跑的是真实的方法体所以能发现“距离10km返回2.5小时”这种业务规则上的问题。同时Spy又记录了调用可以防止“这个方法压根没被调用”的回归。Spy还支持局部替换。继续上面的例子def test_estimator_with_partial_spy(mocker): estimator DeliveryEstimator() # 只替换某个子方法的行为estimate方法本身仍然真实执行 original estimator.estimate mocker.patch.object(estimator, estimate, side_effectlambda d: 9.9) # 但注意这里如果再调用 estimator.estimate就完全返回9.9不再走真实逻辑 assert estimator.estimate(100) 9.9mocker.patch.object(..., wraps...)是另一个非常实用的Spy变体。它会在执行真实函数的同时记录调用还能在真实执行前后做额外校验。def test_delivery_estimator_with_wraps(mocker): estimator DeliveryEstimator() spy mocker.patch.object( estimator, estimate, wrapsestimator.estimate # wraps真实方法 ) result estimator.estimate(30) assert result 5.0 spy.assert_called_once_with(30)这样既执行了真实estimate逻辑又能断言它被调用一举两得。在复杂的业务代码里wraps模式特别适合“我要确认A方法被调用同时A方法内部逻辑也不能跳过”的场景。3.5 Mock和Spy配合使用一个混合型测试示例实战里Mock和Spy经常同时出现。还是订单服务的例子假设我们想测“下单成功后发送发票”这个流程同时希望真实地记录日志而不是把日志也Mock掉。class Logger: def log(self, message: str): # 真实写到某个buffer测试时观察buffer内容 print(message) def test_order_service_with_mock_and_spy(mocker): # 外部依赖用Mock payment_gateway mocker.Mock() payment_gateway.charge.return_value order_009 inventory_client mocker.Mock() inventory_client.check_stock.return_value True # Logger用真实对象Spy记录调用 logger Logger() spy_logger mocker.spy(logger, log) service OrderService( payment_gatewaypayment_gateway, inventory_clientinventory_client, mailermocker.Mock() ) service.place_order( [{sku: pen, quantity: 1, price: 9.9}], buyerexample.com, ) # 外部依赖断言验证行为 payment_gateway.charge.assert_called_once_with(9.9) # 内部真实对象断言验证真实日志被写入 spy_logger.assert_called()你看这套组合拳逻辑非常清晰外部依赖全部Mock确保测试不依赖真实网络内部真实对象用Spy确保真实逻辑跑到位的同时还能观察行为。这比“一口气把Logger也Mock掉”更能发现真实问题。4. 自欺欺人的六大陷阱你以为在隔离依赖其实在造假工具本身没有好坏但使用姿势不对测试就会变成一场自欺欺人的表演。我总结了项目中最常出现的六大陷阱每一个都是我或者同事真真切切踩过的坑。4.1 陷阱一Mock了不该Mock的东西测试全绿但上线就挂最常见的就是把第三方客户端Mock得太彻底。比如支付SDK你用Mock把PaymentClient.create_payment的返回值和异常全部预设好然后测试通过。但真实SDK的签名可能变了、需要额外参数、或者返回值的结构和你Mock的不一样。结果就是测试环境全绿一到线上真调支付接口就爆。这背后的核心矛盾是外部依赖的“契约”需要用集成测试去维护。单元测试里Mock掉它是为了快和稳但你至少要写一个冒烟级集成测试去真实验证SDK的接入是正确的。只Mock不集成等于放弃了对契约的校验。4.2 陷阱二Mock返回值永远正确从不验证“行为是否正确”Mock默认不执行真实逻辑所以它默认“一切正常”。很多人只设置return_value然后就断言结果。但业务代码里最常见的bug恰恰是“调用参数算错了”或者“调用顺序错了”。这些只靠Mock返回值是测不出来的必须配合assert_called_with、assert_not_called、call_count等断言。我建议所有和外部依赖交互的业务逻辑都必须写行为断言。至少断言“被调用一次”和“关键参数正确”。不然你只是在自嗨。4.3 陷阱三把Mock塞进被测对象内部而不是依赖的接口有些人为了省事直接mocker.patch.object(OrderService, place_order)把被测对象自己的方法也Mock了。这等于把测试对象内部逻辑整个跳过。我说过很多次被测对象SUT不能被Mock。正确的隔离方式是创建真实的OrderService把Mock作为构造参数注入进去。这就叫“依赖注入”——被测对象真实运行只有依赖是假的。4.4 陷阱四只验证“调用了”不验证“参数对”举例payment_gateway.charge.return_value order_001然后你只写payment_gateway.charge.assert_called_once()。如果业务代码传了个固定金额99而不是动态计算出的13测试照样绿。必须加上assert_called_with(13.0)。如果你连参数都懒得写那不如别写这个断言。断言到具体参数是Mock测试的灵魂。4.5 陷阱五复用了同一个可变对象导致断言互相干扰有时候测试里把同一个Mock对象传给多个协作者或者用同一个Mock的同一个方法来做多次调用。比如mock_client mocker.Mock() service_a ServiceA(mock_client) service_b ServiceB(mock_client) service_a.run() service_b.run() mock_client.call.assert_called_once() # 大概率会挂因为被调了两次这种问题在跑单个测试时不明显但一旦多个测试共享fixture、共享全局Mock就会出现“偶发失败”。建议每个测试单独创建Mock或者至少明确重置reset_mock()。我见过不少团队被这类“幽灵失败”折磨最后发现是Mock对象跨测试共享了。4.6 陷阱六Spy被滥用成“记录一切”断言形同虚设有的同学喜欢对真实对象包一层Spy然后只断言“确实被调用了”。如果被调用就是全部那这个测试的价值约等于零——它验证不了真实逻辑是否正确。比如spy mocker.spy(service, do_something) service.do_something() spy.assert_called_once()这段测试只证明“你调了一个方法”完全没证明“这个方法返回了正确结果”“参数传递是否正确”“异常分支是否处理”。所以Spy使用时要搭配真实的结果断言用assert_has_calls检查调用历史而不是只数个数。4.7 陷阱七测试与实现细节深度耦合重构一次挂一次这是Mock和Spy在工程层面最大的隐患。当你对内部协作者的方法参数、调用顺序、次数断言过细时测试就和实现细节绑死了。只要重构时调整方法签名或调用顺序测试马上红哪怕业务行为完全没变。这会让团队后期非常痛苦。所以一个重要的原则是断言框架要面向“业务契约”而不是“内部实现”。比如断言“charge被调用且金额正确”而不是断言“先调了check_stock再调了charge再调了send_invoice顺序不能变”。除非调用顺序本身就是业务规则否则别把顺序写死。5. 我的实战经验让Mock和Spy真正服务于隔离依赖理论、代码、坑点都说完了最后分享一些我个人在实际项目里沉淀下来的判断方法和排查经验。5.1 代码审查中的Mock与Spy检查清单我现在看Pull Request里的测试代码基本会按这几个问题过一遍被测对象本身有没有被Mock有打回。Mock的对象是外部依赖还是内部协作者内部协作者被Mock了先问“为什么不测真实协作”。有没有对返回值设置了断言只看调用次数的建议补上参数断言。Mock/Spy的fixture会不会跨测试共享会要求每个测试独立创建。有没有针对真实依赖的集成测试只有Mock没有集成的提醒补一条关键路径的集成测试。测试里有没有对非公有接口的过度断言如果重构就需要改建议放宽断言。5.2 常见问题排查速查表现象可能原因排查/解决办法测试明明全绿线上却有相关bugMock把真实逻辑跳过了或断言太弱检查是否Mock了被测对象/内部协作者补行为断言加了几个测试后原来通过的测试突然挂了Mock对象被共享调用记录互相干扰每个测试独立创建Mock用后reset_mock单测跑起来特别慢可能误用了Spy导致真实逻辑连了库/网络用Mock隔离外部IOSpy只保留必要场景重构一个方法测试改了几十处断言和实现细节耦合太深回归到“业务契约”层面断言别死盯调用顺序同一个测试跑多次结果不稳定真实依赖没被完全隔离检查是否有未Mock的静态变量/线程/随机数/系统时间集成测试里想验证“某方法确实被调用了”用Spy比Mock更合适用mocker.spy包住真实对象再对所有真实方法做结果断言依赖是第三方SDK但无法直接注入到构造函数可能使用了静态方法或单例用mocker.patch.object替换类级方法尽量把SDK客户端改成可注入5.3 我比较推崇的三条使用原则第一Mock和Spy是“隔离依赖”的工具不是“逃避测试”的借口。能用真实逻辑测通的地方优先保证真实逻辑参与执行只有涉及外部IO、网络、时间等不稳定因素时才把它们隔离掉。第二外部依赖优先Mock内部协作者优先真实逻辑Spy。这一条能解决80%的“Mock滥用”问题。你要隔离的是环境而不是业务本身。第三行为验证要精致化。写出assert_called_once_with(expected_arg)比写十个assert_called_once()有价值得多。Mock的价值不在于“你有替身可用”而在于“你能确认你的代码用正确的方式和外界协作”。最后再分享一个我自己的“测试体检”小技巧每过一段时间我就会把一个测试类里所有Mock的return_value随机改成异常或错误值跑一遍测试看看测试能不能像预期那样失败。如果测试仍然全绿说明这套测试大概率在自欺欺人——它连Mock坏了都不在乎。给代码留一面镜子比让测试永远绿下去重要得多。
返回列表