Mock回放测试的核心原理

讲Mock回放之前,我想先问个问题:你们有没有遇到过这种场景——Crash复现了,但线上环境已经被重启,日志也刷没了。你盯着一个堆栈堆栈里缺了几帧,根本不知道用户操作到哪一步才炸的?

我遇到过,还不止一次。早期我做客户端稳定性测试时,最头疼的就是「能复现但难定位」。后来接触到Mock回放测试,思路一下子打开了。说白了,它的本质就一句话:

把真实请求录下来,在隔离环境中「原样重放」,观察Crash是否复现。

为什么叫「Mock回放」?

这个名字有点绕。我拆开讲:

  • Mock —— 伪造外部依赖。比如数据库、缓存、第三方API,全用模拟对象替代。你想想看,你在重放时还想真的调用生产环境?风险太大。
  • 回放 —— 把录制好的请求/响应序列按时间顺序重新发送给被测系统。

所以Mock回放 = 用模拟替代 + 按原序重放。它和普通压力测试的最大区别在于:回放的数据来自真实用户,不是合成脚本。这一点非常关键。

核心流程:录、存、放、比

我个人习惯把Mock回放分成四个阶段。画了张图,你们先看整体脉络:

Mock回放测试核心流程 ① 录制 拦截真实请求/响应 ② 存储 序列化到文件/DB ③ 回放 Mock外部依赖后重放 ④ 比对 结果断言 & 异常检测 重复使用:存储的用例可 多次回放,无需重新录制 关键:比对结果中查找 Crash堆栈 & 异常日志 录制阶段 & 存储阶段 回放阶段 & 比对阶段

下面我逐一展开每个阶段。注意,这里有不少我踩过的坑,我会直接说出来。

阶段一:录制——不是所有流量都值得录

录制的核心是无侵入地拦截请求。我常用的是AOP(面向切面)或代理模式,在客户端和服务端之间插一个拦截层。

// 伪代码示例:AOP拦截器
@Around("execution(* com.example.service.*.*(..))")
public Object recordAndProceed(ProceedingJoinPoint pjp) {
    Object[] args = pjp.getArgs();
    String method = pjp.getSignature().getName();
    // 录制请求
    recorder.save(new RequestRecord(method, args));
    Object result = pjp.proceed();
    // 录制响应
    recorder.updateResponse(result);
    return result;
}

我曾经踩过的坑:一开始我把所有RPC调用都录了,结果存储暴涨,回放时还出现了时序错乱。后来我只录导致Crash的那条关键链路——一般从用户点击到崩溃前最后10秒就够了。别贪多。

录制还需要注意几点:

  • 敏感数据脱敏:用户名、密码、不能明文录,要hash或抹掉。
  • 保留上下文:比如Session ID、traceId,回放时要能关联上。
  • 标记Crash点:强烈建议在Crash发生时立即打标记,这样回放时能直接定位到爆炸的那一步。

阶段二:存储——选对序列化格式

存储格式我推荐两种:

格式 优点 缺点 适用场景
JSON 可读性好,调试方便 体积大,序列化慢 少量用例、开发期
Protobuf 体积小,速度快,支持schema演进 可读性差,需要schema 大规模流量录制

我个人习惯:开发阶段用JSON,方便手改数据;CI/CD里用Protobuf,能节省存储和网络开销。另外注意,存储时最好把timestamp和threadId一起记下来,因为多线程并发问题在回放时很容易暴露。

小技巧:每条记录加一个唯一RequestId,方便回放时与日志关联。我曾经靠这个把某个间歇性Crash从5秒定位缩短到30秒。

阶段三:回放——Mock的关键就在这里

回放时最怕什么?依赖不响应。你重放了一个请求,但数据库不在、缓存超时、第三方服务拒绝连接——Crash没复现,但系统挂了。

所以回放前必须把所有外部依赖Mock掉。我通常用MockServer或WireMock来搭建假服务。

// 以WireMock为例,录制阶段已经生成了stub文件
WireMockServer server = new WireMockServer(options().port(8080));
// 加载录制的stub映射
server.loadMappingsFrom("recorded_stubs/");
server.start();

// 回放时,被测系统所有请求都会打到WireMock上
// WireMock会按照录制时的响应返回

这里有个很关键的细节:回放顺序要严格保持录制时的先后。比如录制时请求A先于请求B,回放时如果顺序颠倒,可能触发完全不同的状态,Crash可能就复现不了了。

你想想看:用户先点了登录(写Cookie),再点支付(读Cookie)。如果你回放时先放支付再放登录,Cookie读不到,Crash就不出现了——这就是假阴性。所以我回放时一定会用队列保证顺序。

阶段四:比对——找Crash,也找差异

回放结束不等于测试结束。最关键的是对比回放结果与录制结果。比对维度包括:

  1. 返回值是否一致 —— Mock过的服务返回肯定是固定的,但被测系统自身的返回值可能因时间、随机数变化。我一般允许一定容差。
  2. 异常/错误码是否匹配 —— 如果录制时抛了NullPointerException,回放时也应该是同一个异常。
  3. Crash堆栈是否一致 —— 这是我们的核心关注点。堆栈的类名、行号可以不完全一样(因为代码更新),但异常类型和调用链应该相似。
  4. 额外副作用 —— 比如回放后是否多写了日志、多发送了消息,这些可能是Bug的前兆。
// 比对逻辑伪代码
boolean match = true;
if (!expectedException.equals(actualException)) {
    match = false;
    logger.error("异常不匹配: expected={}, actual={}", expectedException, actualException);
}
if (!expectedStackTrace.contains(actualStackTrace)) {
    match = false;
    logger.warn("堆栈有偏差,需人工审核");
}
if (match) {
    logger.info("Crash复现成功,比对通过");
} else {
    logger.warn("Crash可能未复现或被新代码修复");
}

我遇到过很坑的情况:有一次回放时Crash没出现,我以为Bug被修了。结果上线后又炸了。后来发现是Mock时把一个输入参数类型改错了,导致异常路径没走到。所以比对时不能只看有没有Crash,还要看执行路径是否一致。建议回放时也把日志级别提到DEBUG,对比关键log。

几个核心原则总结

写到这里,我把Mock回放测试的几个核心原则列出来,你们平时做测试时可以直接当checklist用:

  • 隔离性:回放环境必须和生产隔离,不能修改真实数据。
  • 确定性:在Mock不变、输入不变的情况下,每次回放结果必须一致。
  • 顺序性:多请求场景严格按录制时序重放。
  • 可追溯:每条回放记录都能对应到录制时的原始请求,方便排查偏差。
  • 最小化:只录制和回放与Crash相关的流量,不要贪多嚼不烂。