单元测试中的 Mock 和 Stub 区别
单元测试中的 Mock 和 Stub 区别
在编写单元测试时,我们经常需要隔离被测代码的依赖,以保证测试的稳定性、速度和可重复性。这时就需要使用 测试替身 (Test Doubles) 来替代真实依赖。最常见的两种测试替身就是 Stub 和 Mock。初学者往往容易混淆它们,但它们的目的和使用方式截然不同。本文将帮你彻底理清这两者的区别,并通过 Python 代码示例让你轻松掌握。
什么是测试替身?为什么需要它们?
单元测试的目标是验证“单元”本身的逻辑,而不是它所依赖的外部系统。如果被测代码依赖于数据库、网络服务、文件系统或其他模块,这些依赖可能带来以下问题:
- 运行缓慢(例如真实网络请求)
- 环境依赖强(需要特定数据库可用)
- 行为不稳定(返回数据可能变化)
- 难以模拟边缘情况(如网络超时、权限错误)
测试替身 就是用来替代这些真实依赖的轻量级对象,让我们能够完全控制被替代组件的行为。常见的测试替身包括 Dummy、Fake、Stub、Mock、Spy。其中 Stub 和 Mock 是日常工作中使用最频繁的两种,也是我们需要重点区分的角色。
Stub(桩):只提供预设数据,不关心如何被调用
Stub 是一个“哑”对象,它只提供我们预先设定好的返回值,不关心自己被调用了多少次、用什么参数调用。它的唯一目的就是让被测代码能够顺利执行下去,获取所需的值。
Stub 的核心特征
- 提供返回值: 对特定的方法调用返回预设的固定结果。
- 不验证交互: 不会记录或检查它被调用的细节。
- 状态验证为主: 测试最终通过验证被测代码的状态(某个对象的属性、返回值等)来判断是否正确。
Stub 示例
假设有一个 WeatherService 类,它从外部 API 获取天气信息。我们想测试 OutfitRecommender 类,它根据天气返回穿衣建议。
# 被测代码
class OutfitRecommender:
def __init__(self, weather_service):
self.weather_service = weather_service
def recommend(self, city):
temp = self.weather_service.get_temperature(city)
if temp > 25:
return "T-shirt"
else:
return "Jacket"
在测试中,我们不想真的调用外部 API,于是创建一个 Stub 来替代 weather_service。
from unittest import TestCase
from unittest.mock import MagicMock
class TestOutfitRecommender(TestCase):
def test_recommend_cold_weather(self):
# 创建一个 Stub 替身
stub_service = MagicMock()
# 设定 Stub 的行为:无论收到什么参数,都返回 10 度
stub_service.get_temperature.return_value = 10
recommender = OutfitRecommender(stub_service)
result = recommender.recommend("Beijing")
# 验证最终状态(返回值)是否正确
self.assertEqual(result, "Jacket")
# 注意:这里并没有验证 stub_service 被如何调用!
在这个例子中,stub_service.get_temperature.return_value = 10 让 Stub 在任何调用下都返回 10。我们的测试只关心 recommend() 在低温下是否返回 “Jacket”,完全不关心 Stub 的调用细节。这就是典型的 Stub 用法。
Mock(模拟):不仅提供假数据,更要验证交互行为
Mock 是比 Stub 更“聪明”的测试替身。它也能预设返回值(像 Stub 一样),但更重要的是,它会记录自己被调用的方式,并允许我们在测试中验证这些调用是否按预期发生。Mock 关注的是行为交互,即被测代码是否以正确的方式与依赖进行了通信。
Mock 的核心特征
- 提供返回值(可选): 同样可以预设返回值,但它不是 Mock 存在的首要目的。
- 验证交互: 测试会明确检查 Mock 对象是否被正确调用了(调用次数、参数、调用顺序等)。
- 行为验证为主: 测试通过验证被测代码对外部依赖的调用行为来确保逻辑正确。
Mock 示例
考虑一个 UserRegistration 类,它需要保存用户并发送通知邮件。我们关心的是:当调用 register() 时,是否确实调用了send_email() 方法,并且参数是正确的。
class UserRegistration:
def __init__(self, user_repo, email_service):
self.user_repo = user_repo
self.email_service = email_service
def register(self, username, email):
self.user_repo.save(username, email)
# 核心交互:必须发送一封欢迎邮件
self.email_service.send_email(email, "Welcome!")
测试代码将使用 Mock 来替代 email_service,并验证 send_email 是否被正确调用。
from unittest import TestCase
from unittest.mock import MagicMock
class TestUserRegistration(TestCase):
def test_register_sends_welcome_email(self):
# 创建 Mock 替身
mock_email_service = MagicMock()
mock_user_repo = MagicMock()
registration = UserRegistration(mock_user_repo, mock_email_service)
registration.register("alice", "[email protected]")
# 验证交互行为:send_email 被调用了一次,且参数正确
mock_email_service.send_email.assert_called_once_with(
"[email protected]", "Welcome!"
)
# 也可以不关心返回值,只关心是否被调用
这里的关键在于 assert_called_once_with,它明确验证了交互行为。如果我们的代码忘记调用 send_email 或参数错误,测试会立刻失败。而 mock_user_repo 也是一个 Mock(我们可能也会验证 save 是否被调用),但如果你只关心返回值而不验证调用,它就可以被视为一个 Stub。
Stub 与 Mock 的关键区别
虽然很多时候我们用同一个库(如 Python 的 unittest.mock 或 Java 的 Mockito)创建这两种替身,但它们在目的和验证方式上有本质区别。
| 维度 | Stub | Mock |
|---|---|---|
| 主要目的 | 提供测试所需的间接输入,让代码顺利运行 | 验证被测代码是否以正确方式与依赖交互 |
| 验证方式 | 状态验证:检查被测代码的最终结果 | 行为验证:检查依赖是否被正确调用 |
| 关心什么 | 被测代码“得到了什么” | 被测代码“做了什么” |
| 典型断言 | assertEqual(result, expected_value) |
assert_called_with(...), assert_called_once() |
| 是否关心调用细节 | 完全不关心 | 高度关心(参数、次数、顺序) |
| 失败原因 | 返回值计算错误、逻辑分支错误 | 未发生预期的调用、参数错误、调用次数不对 |
简单记忆口诀:
- 当你只关心 “从依赖那里拿回什么值” 时,用 Stub。
- 当你需要确保 “依赖的某个方法是否被正确触发” 时,用 Mock。
如何选择:Stub 还是 Mock?
在实际开发中,一个测试可能同时需要两种行为,但遵循以下原则可以帮你写出更清晰、可维护的测试。
优先使用 Stub(状态验证)的情况
- 返回值是后续计算的输入,比如查询数据库获得用户信息,再基于该信息做处理。你只想要一个假用户,不关心查询方法被调用了几次。
- 被测方法的最终产出就是返回值或对象状态变化,而不是对外部的命令调用。
- API 本身就像是一个“数据提供者”,例如
get_price(),fetch_config()。
必须使用 Mock(行为验证)的情况
- 被测代码需要向外部系统发送命令,且没有返回值可供观察。例如发送邮件、记录日志、推送消息。如果不验证行为,就无法知道命令是否发出。
- 需要确保重要的交互顺序或精确参数,例如“必须先保存订单再扣减库存”。
- 依赖是副作用发生器,你不能通过查询去验证副作用发生了,只能通过验证调用。
警惕过度使用 Mock:
如果测试中充满了大量的assert_called_with,会使得测试与实现细节高度耦合,未来任何内部重构都可能导致测试失败,即使行为完全正确。尽量用 Stub 验证最终结果,只在必须验证交互的地方使用 Mock。记住:测试行为,而不是实现。
实践建议与常见陷阱
-
角色可以重叠,但意图要清晰
同一个 MagicMock 对象可以既设置return_value(充当 Stub),又使用assert_called_with(充当 Mock)。但为了代码可读性,最好让一个对象在测试中只扮演一种清晰的角色。如果既需要返回值又需要验证调用,可以明确命名变量为mock_email_service或stub_price_api。 -
不要用 Mock 验证自己的业务逻辑
如果被测方法返回了一个值,优先使用 Stub + 状态断言来验证业务逻辑。例如,不要 Mock 掉list.sort()然后验证它被调用了,而应该检查列表本身是否有序。 -
Stub 不应当让测试失败
Stub 只提供数据,它本身的设计初衷就是“永远工作”。如果你需要模拟依赖抛出异常,可以使用stub_service.get_temperature.side_effect = Exception("API down"),这仍然是 Stub 的一种预设行为,但测试关注的并不是“是否调用了温度服务”,而是“当温度服务挂掉时,我们的代码是否健壮”。 -
经典的学校比喻
想象老师(测试)要考察学生(被测代码)的计算能力:- Stub:老师直接告诉学生“假设当前温度是10度”,然后看学生得出的穿衣建议是否正确。完全不关心学生问了老师几次。
- Mock:老师要求学生必须向图书馆发送一份借书申请单,然后检查学生是否真的去做了,并且申请书内容正确。
总结
| 角色 | 定义 | 典型断言语 |
|---|---|---|
| Stub | 为测试提供间接输入的假对象,不记录调用详情 | assert result == "Jacket" |
| Mock | 记录调用细节,用于验证行为交互的假对象 | mock.assert_called_once_with("[email protected]", "Welcome!") |
理解 Stub 和 Mock 的区别,是写好单元测试、构建稳定可维护测试套件的关键一步。下次写测试时,先问自己:
- 我是在给被测代码 “喂数据”(Stub),还是在检查 “它有没有正确使唤别人”(Mock)?
- 这个测试最好通过验证什么来保证正确性?是最终产出,还是交互过程?
答案将直接告诉你该用哪种替身。希望这篇教程能让你彻底告别 Mock/Stub 的混淆,写出更强大、更清晰的单元测试。