Mock 概念与在测试中的应用

说到 Mock,很多刚入行的朋友会把它和“造假”划等号。其实没那么复杂——Mock 就是给被测对象找个替身。在 Crash 场景回放测试里,这个替身尤其关键:它帮你隔离外部依赖,只专注验证核心逻辑的崩溃恢复能力。

我个人理解,Mock 的核心价值就四个字:可控、可测。你想想看,Crash 环境往往需要模拟网络超时、数据库损坏、第三方服务宕机这些极端情况。如果没有 Mock,你总不能真的把生产服务器拔了吧?

Mock 在 Crash 回放测试中的角色 被测系统 (SUT) 网络服务 数据库 第三方 API Mock 模拟层 隔离外部依赖 • 可控崩溃场景 • 稳定的测试结果 • 快速反馈循环 • 无需真实环境 流程:被测请求 → Mock 拦截 → 返回预设数据 → 验证 Crash 处理

Mock 的本质:不是造假,是解耦

我记得刚开始带团队做 Crash 回放时,有个同事直接连生产数据库做测试。结果凌晨两点,一条测试数据把线上用户表弄乱了。教训惨痛。Mock 就是帮你把这个风险前置到本地。

在测试领域,Mock 特指——用轻量级的模拟对象替换真实的外部组件。它只关心:返回什么、抛出什么异常、延迟多久。至于真实组件内部怎么实现的,Mock 一概不管。

Mock 与 Stub 的区别(很多人搞混)

维度MockStub
核心目的验证行为交互提供预设返回值
状态验证检查是否被调用、调用次数、参数不关心调用过程
适用场景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 的常见误区(我全犯过)

  1. Mock 过多导致测试失去意义 —— 如果整个系统除了你的业务逻辑全部被 Mock,那测试测的其实是“Mock 框架是否能正常工作”。我后来给自己定了个规矩:只 Mock 调用链的边界层,比如网络、数据库、文件。
  2. 忽视 Mock 的清理 —— 每个测试用例结束后一定要重置 Mock。我当时有一个大型测试套件,由于忘记清理 Mock,导致后续用例的 mock 行为被重复激活,又花了三天 Debug。
  3. 直接 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 场景适配度
JavaMockito + PowerMock高(支持静态/私有方法 Mock)
KotlinMockK极高(原生协程支持,适合异步 Crash)
Pythonunittest.mock / pytest-mock高(自动清理,适合快速迭代)
Gogomock / mockery中(需配合接口,但回放性能好)

我个人的经验是:选框架时优先看它对异步/协程的支持,因为 Crash 场景大多数发生在异步回调或网络请求途中。MockK 在处理挂起函数时特别干净,我这两年已经全部迁移到它了。

总结一下(几句干货)

Mock 不是万能的,但没有 Mock 做 Crash 回放测试是万万不能的。 它帮你隔离了环境的不确定性,让你可以精准地复现任何一个崩溃路径。记住三点:

  • 只 Mock 边界层,别 Mock 内部逻辑
  • 每个 Crash 场景要包含「正常→异常→恢复」三个子用例
  • 用完 Mock 立刻清理,防止测试间污染