先交代背景。我们做的是多租户微信小程序 SaaS,主工程 Java 21 + Spring Boot + PostgreSQL,250 个测试类、1241 个测试方法,测试覆盖算得上扎实。但有一块一直是短板:与微信开放平台的交互——特约商户进件、支付下单、回调通知、分账、对账单下载。这些代码承担着全系统最高的资金风险,测试路径却最薄。这篇文章讲我们怎么花一周自建了一个拟真模拟器,把这块补上。
一、背景:官方沙箱测不了的东西
沙箱的问题不是"不好用",而是它只提供正常路径的一个子集。说三堵我们真实撞过的墙。
第一堵墙:进件驳回分支无法复现。驳回原因码有几十种,我们为每种都配了不同的引导文案与重填逻辑。去年十月联调,同事在工位上嘟囔了一句:"沙箱里全是秒过,驳回只能在注释里测。"——想走驳回分支,只能临时注释掉代码里的状态判断,测的是被改过的代码,不是会上线的代码。后果很快兑现:驳回引导上线两个月后迎来第一次真实驳回,重填页把商户卡进了死循环;我们引以为傲的分支覆盖,一个都没在真实报文上跑过。
第二堵墙:回调乱序没法造。真实回调会延迟、会重复送达、会乱序——"退款发起"比"支付成功"先到、"分账完成"比"分账受理"晚到,都是线上发生过的事。我们的幂等表和状态机就是为这些时序写的,可沙箱的回调永远立刻、按序、恰好一次。相当于为暴雨设计的排水系统,只在晴天验收。
第三堵墙:"审核中"状态的时长不可控。进件提交后有一段"审核中"中间态,真实环境几分钟到几天不等,我们据此设计了轮询退避和超时转人工。沙箱里这个状态几乎不存在,提交瞬间跳到终态。第几次轮询后退避、连续失败几次转人工,这些"时长敏感"的逻辑,在只给终态的沙箱里等于裸奔。
测试的价值大半在异常路径,而沙箱恰恰只给正常路径。这就是全部动机。
二、设计思路:按契约建模,不按实现模仿
第一个、也是最重要的决定:模拟器只实现"我们依赖的契约",不模仿"对方的实现"。
我们没能力也没必要复刻开放平台的内部逻辑,只把主工程调用方真正依赖的东西固化下来:接口入参、响应码表、回调报文结构、进件状态机。在此之上加一层可控的异常注入开关。四个核心开关,每个对应一类线上真实发生过的事故:
# 模拟器异常注入开关(脱敏示例,默认全关)
fault:
delay_ms: 3000 # 延迟:回调压后 3 秒送达,验证补偿先到、回调后到时不重复入账
reject: CERT_EXPIRED # 驳回:进件直接驳回并携带原因码,驱动驳回重填全流程
timeout: true # 超时:状态查询挂起直至客户端超时,验证"审核中"轮询退避与转人工兜底
unknown: true # 未知:支付下单返回结果未知,触发查单补偿链路
单开一个开关测单类故障,组合才是价值所在:unknown + delay 复现"下单结果未知、回调又迟迟不来"的窗口期,查单补偿最怕的就是它;reject + timeout 复现"驳回之后查状态还超时",验证商户端不会停在无反馈的空白页。用例先拨开关,再调业务,断言系统在每种逆境下的表现。顺带的好处是,这套开关配置成了活文档——新人把带着开关的用例读一遍,就知道这条链路在线上到底会遭遇什么。
三、架构要点
| 要点 | 做法 |
|---|---|
| 独立进程 | Python(FastAPI)起的独立 HTTP 服务,与主工程语言解耦——测试编排生态在 pytest 手里 |
| 状态可查询 | GET /mock/state 可查任意商户进件走到哪一步、哪些回调已发 |
| 数据可重置 | POST /mock/reset 一键清零,保证用例隔离、可重复执行 |
| 时钟可快进 | 提供时间快进接口,10 分钟关单、T+1 对账不再靠真等 |
| 同一客户端接口 | 真实适配器与 Mock 适配器实现同一接口,MOCK/REAL 切换收在配置层 |
// 脱敏伪代码:模式切换收在配置层
@Bean
PaymentClient paymentClient(@Value("${pay.mode}") String mode) {
return "MOCK".equalsIgnoreCase(mode)
? new MockPaymentClient(mockBaseUrl) // 真发 HTTP,不是进程内 stub
: new WxPaymentClient(credentials);
}
强调一点:Mock 适配器不是进程内 stub,而是真的走 HTTP 打到模拟器服务。序列化、签名校验、超时重试这些代码必须真正躺在测试路径上——mock 掉了客户端,等于把它们唯一的锻炼机会也 mock 掉了。
四、端到端"黄金旅程"测试
单接口 Mock 用单元测试就够了,模拟器真正的价值是跑贯穿全链路的旅程。我们把它固化成一份用例清单,pytest 驱动,CI 每次提交必跑:
- 消费者下单,业务库生成待支付订单;
- 发起支付——开关预拨
unknown,下单接口返回"结果未知"; - 服务端查单补偿启动,二次查询确认已支付,订单转已支付态;
- 开关预拨
delay:支付回调迟到 3 秒,断言幂等表拦截,不重复入账; - 消费者到店,店员扫码核销,核销记录落库;
- 触发分账,模拟器回执"分账完成";
- 时钟快进至 T+1,下载对账单;
- 双侧核对:模拟器侧账单与业务库的订单、核销、分账记录逐条对平。
断言分两层:一层看业务库——订单、核销、分账单的终态是否正确;一层看模拟器侧——已发的回调、已下载的账单,与业务库两边能不能对得上。两边对得上,才算钱没有记错。27 个业务域里凡是沾资金的域,回归底线就是这条旅程。
五、踩坑清单
- 模拟器漂移(最大的坑)。复盘一个真实事故:某个周一早晨九点,值班同事在群里甩了张截图:"对账全红了,昨晚没人动过代码。"排查两小时,结论是对面周五深夜上线,把对账单里一个金额字段从整数改成了字符串;模拟器仍按旧结构返回,我们的测试全绿,联调当场翻车。事后补上契约测试兜底:一组用例每天定时打真实环境的只读接口,比对响应结构与模拟器是否一致,漂移即报警。上线第二周它就逮住一次回调报文新增字段——报警落在凌晨,比事故早发现十四个小时,代价只是一条值班消息。
- 开关默认值要"温柔"。默认全关(模拟理想行为),异常必须显式打开。早期一版
reorder默认开着,两个不相关用例偶发互败,我们排查到凌晨一点,最后发现是共用同一个模拟器实例的开关状态。此后用例夹具强制先 reset 再拨开关。 - 别把模拟器养得"过于守规矩"。早期版本回调永不重发,我们的重试代码两个月没被执行过;上线第一周,真实环境一次重复回调直接打了个措手不及。
- 时钟要可控。10 分钟未支付自动关单、T+1 对账都涉及时效。第一版我们用真等,一条用例干等十分钟,CI 时长被活活拖长,第二天就换成了可拨时钟。
六、三句话总结
第三方依赖的测试价值大半在异常路径,官方沙箱只给正常路径,所以得自己造。模拟器只实现你依赖的契约、配上可拨的异常开关,比复刻对方实现便宜一个数量级。模拟器一定会漂移,定期打真实环境的契约测试才是最后兜底。
运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。
