Mock 概念与在测试中的应用
说到 Mock,很多刚入行的朋友会把它和“造假”划等号。其实没那么复杂——Mock 就是给被测对象找个替身。在 Crash 场景回放测试里,这个替身尤其关键:它帮你隔离外部依赖,只专注验证核心逻辑的崩溃恢复能力。
我个人理解,Mock 的核心价值就四个字:可控、可测。你想想看,Crash 环境往往需要模拟网络超时、数据库损坏、第三方服务宕机这些极端情况。如果没有 Mock,你总不能真的把生产服务器拔了吧?
Mock 的本质:不是造假,是解耦
我记得刚开始带团队做 Crash 回放时,有个同事直接连生产数据库做测试。结果凌晨两点,一条测试数据把线上用户表弄乱了。教训惨痛。Mock 就是帮你把这个风险前置到本地。
在测试领域,Mock 特指——用轻量级的模拟对象替换真实的外部组件。它只关心:返回什么、抛出什么异常、延迟多久。至于真实组件内部怎么实现的,Mock 一概不管。
Mock 与 Stub 的区别(很多人搞混)
| 维度 | Mock | Stub |
|---|---|---|
| 核心目的 | 验证行为交互 | 提供预设返回值 |
| 状态验证 | 检查是否被调用、调用次数、参数 | 不关心调用过程 |
| 适用场景 | Crash 重放、超时重试、异常链路 | 普通数据依赖 |
| 复杂度 | 较高,需验证行为 | 较低,只需返回数据 |
实际项目中我通常混合使用。做 Crash 场景时,Mock 占比超过 70%,因为我们要刻意验证系统在极端条件下的行为是否正确。
Mock 在 Crash 回放测试中的 4 大核心应用
这些年我踩过不少坑,归纳下来,Mock 在回放测试中主要解决四类问题。咱们一个个聊。
1. 模拟网络层崩溃
Crash 场景最常见的诱因就是网络异常。我建议你至少覆盖这三种情况:
- 连接超时 —— 模拟 TCP 握手中断
- 数据校验失败 —— 返回损坏的 body 或 header
- DNS 解析失败 —— 域名根本无法解析
// 以 Kotlin + MockK 为例
val mockClient = mockk<HttpClient>()
every { mockClient.get(any()) } throws
ConnectTimeoutException("模拟网络超时")
// 被测试代码
val result = crashRecovery.fetchData(mockClient)
// 验证重试行为
verify(exactly = 3) { mockClient.get(any()) }
避坑提醒: 我曾经只 mock 了第一次调用的异常,结果生产环境重试逻辑把数据库写废了。后来我坚持每个异常场景至少测三次重试路径,才把坑填平。
2. 模拟数据库损坏
数据库文件被 crash 写坏,是客户端崩溃的典型原因。Mock 时不要只返回空数据,要模拟:
- SQLite 返回
SQLITE_CORRUPT错误码 - 数据库文件大小异常(比如 0 字节)
- 索引损坏导致的慢查询(响应 > 30 秒)
说白了,你得让被测系统在数据库“半死不活”的状态下,还能优雅降级。而不是直接抛个未捕获异常导致二次崩溃。
3. 模拟第三方 SDK 异常
广告 SDK、推送 SDK、埋点 SDK —— 这些第三方组件经常在内部静默抛出异常。如果你没 mock,测试环境一旦触发,整个回放流程就卡死了。
我个人的做法是:把第三方 SDK 全部接口抽象成接口层,然后只 Mock 那个接口。这样替换成本极低。
警告: 不要 Mock 原生 SDK 的 final 类或静态方法。我曾经在 Android 上 mock 了一个系统 Service 的静态方法,结果导致所有测试用例都在同一个进程内互相污染。排查了整整两天。
4. 模拟文件系统异常
Crash 场景中,磁盘写满、文件权限被篡改、临时目录被删除 —— 这些 edge case 只有 Mock 能模拟。我建议用如下模式:
// 模拟文件写入失败
val mockFileSystem = mockk<FileSystem>()
every { mockFileSystem.write(any(), any()) } throws
IOException("No space left on device")
// 验证降级逻辑
assertTrue(crashRecovery.fallbackToCache())
Mock 的常见误区(我全犯过)
- Mock 过多导致测试失去意义 —— 如果整个系统除了你的业务逻辑全部被 Mock,那测试测的其实是“Mock 框架是否能正常工作”。我后来给自己定了个规矩:只 Mock 调用链的边界层,比如网络、数据库、文件。
- 忽视 Mock 的清理 —— 每个测试用例结束后一定要重置 Mock。我当时有一个大型测试套件,由于忘记清理 Mock,导致后续用例的 mock 行为被重复激活,又花了三天 Debug。
- 直接 Mock 第三方闭源库内部类 —— 一旦库升级,内部类名或方法签名变了,你的 Mock 就全崩。我建议用适配器模式包一层,然后再 Mock 适配器。
一个简单的 Crash 回放 Mock 示例
假设你要测一个支付模块的崩溃恢复逻辑。真实场景:网络闪断导致支付请求断开,用户重试时需恢复上个未完成的订单。
// 伪代码 —— 展示 Mock 回放的核心思路
@Test
fun `should recover payment order after network crash`() {
// 1. 准备 Mock 环境
val mockOrderApi = mock<OrderApi>()
when(mockOrderApi.queryPendingOrder())
.thenReturn(PendingOrder(id = "2024"))
.thenThrow(NetworkException("模拟crash"))
// 2. 注入 Mock (通过依赖注入或构造器)
val paymentManager = PaymentManager(mockOrderApi)
// 3. 第一次调用 —— 获取待支付订单(正常)
val pending = paymentManager.getPendingOrder()
// 4. 第二次调用 —— 发起支付并模拟crash
assertThrows<NetworkException> {
paymentManager.submitPayment(pending)
}
// 5. 回放 —— 验证恢复逻辑
val recoveryResult = paymentManager.recoverAfterCrash()
assertEquals(RecoveryStatus.SUCCESS, recoveryResult)
verify(mockOrderApi, times(1)).rollback(pending.id)
}
你看,核心就是:先用 Mock 记录正常行为,再注入异常迫使 Crash,最后验证恢复流程是否调用了正确的回滚接口。整个闭环都在内存里完成,快、准、可控。
我的小技巧: 在 Mock 返回数据时,我习惯用工厂函数生成「抖动数据」——比如随机把字段置空、把数字变成负数、字符串超长。因为这些恰恰是 Crash 回放中最容易被忽略的边界。
如何选择 Mock 框架
不同的技术栈有不同的推荐。我把这些年用过的几个主流框架列一下:
| 语言 | 推荐框架 | Crash 场景适配度 |
|---|---|---|
| Java | Mockito + PowerMock | 高(支持静态/私有方法 Mock) |
| Kotlin | MockK | 极高(原生协程支持,适合异步 Crash) |
| Python | unittest.mock / pytest-mock | 高(自动清理,适合快速迭代) |
| Go | gomock / mockery | 中(需配合接口,但回放性能好) |
我个人的经验是:选框架时优先看它对异步/协程的支持,因为 Crash 场景大多数发生在异步回调或网络请求途中。MockK 在处理挂起函数时特别干净,我这两年已经全部迁移到它了。
总结一下(几句干货)
Mock 不是万能的,但没有 Mock 做 Crash 回放测试是万万不能的。 它帮你隔离了环境的不确定性,让你可以精准地复现任何一个崩溃路径。记住三点:
- 只 Mock 边界层,别 Mock 内部逻辑
- 每个 Crash 场景要包含「正常→异常→恢复」三个子用例
- 用完 Mock 立刻清理,防止测试间污染