ENGINEERING NOTES · 工程笔记

当沙箱不够用:自建微信开放平台模拟器的经验

先交代背景。我们做的是多租户微信小程序 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 每次提交必跑:

  1. 消费者下单,业务库生成待支付订单;
  2. 发起支付——开关预拨 unknown,下单接口返回"结果未知";
  3. 服务端查单补偿启动,二次查询确认已支付,订单转已支付态;
  4. 开关预拨 delay:支付回调迟到 3 秒,断言幂等表拦截,不重复入账;
  5. 消费者到店,店员扫码核销,核销记录落库;
  6. 触发分账,模拟器回执"分账完成";
  7. 时钟快进至 T+1,下载对账单;
  8. 双侧核对:模拟器侧账单与业务库的订单、核销、分账记录逐条对平。

断言分两层:一层看业务库——订单、核销、分账单的终态是否正确;一层看模拟器侧——已发的回调、已下载的账单,与业务库两边能不能对得上。两边对得上,才算钱没有记错。27 个业务域里凡是沾资金的域,回归底线就是这条旅程。

五、踩坑清单

  1. 模拟器漂移(最大的坑)。复盘一个真实事故:某个周一早晨九点,值班同事在群里甩了张截图:"对账全红了,昨晚没人动过代码。"排查两小时,结论是对面周五深夜上线,把对账单里一个金额字段从整数改成了字符串;模拟器仍按旧结构返回,我们的测试全绿,联调当场翻车。事后补上契约测试兜底:一组用例每天定时打真实环境的只读接口,比对响应结构与模拟器是否一致,漂移即报警。上线第二周它就逮住一次回调报文新增字段——报警落在凌晨,比事故早发现十四个小时,代价只是一条值班消息。
  2. 开关默认值要"温柔"。默认全关(模拟理想行为),异常必须显式打开。早期一版 reorder 默认开着,两个不相关用例偶发互败,我们排查到凌晨一点,最后发现是共用同一个模拟器实例的开关状态。此后用例夹具强制先 reset 再拨开关。
  3. 别把模拟器养得"过于守规矩"。早期版本回调永不重发,我们的重试代码两个月没被执行过;上线第一周,真实环境一次重复回调直接打了个措手不及。
  4. 时钟要可控。10 分钟未支付自动关单、T+1 对账都涉及时效。第一版我们用真等,一条用例干等十分钟,CI 时长被活活拖长,第二天就换成了可拨时钟。

六、三句话总结

第三方依赖的测试价值大半在异常路径,官方沙箱只给正常路径,所以得自己造。模拟器只实现你依赖的契约、配上可拨的异常开关,比复刻对方实现便宜一个数量级。模拟器一定会漂移,定期打真实环境的契约测试才是最后兜底。

运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。

本文为位元跃迁原创内容,仅代表编辑观点,不构成经营或投资建议;文中涉及的品牌与案例仅作公开信息分析示例。

LET'S TALK

把方法用进你的生意

如果你正在为门店做数字化规划,欢迎和我们聊聊。位元跃迁是微信支付合作伙伴、微信开放平台第三方平台,提供小程序定制、开发咨询与企业数字化服务。

18601279913

业务咨询 · 项目合作 · 代理商合作

了解位元跃迁的服务 →
位元跃迁业务联系微信二维码
微信咨询