ELL3N HSU
← 文章

支付测试 SDK:复用请求能力时,怎样保留业务差异

从多业务复用和存量子类兼容出发,解释请求与业务分层、调用契约,以及按返回码重试的适用边界。

我在供应商派驻微信支付期间,参与委托代扣与商户资金业务的可测性 API 建设,并主导商户资金测试 SDK 重构。这里讨论一个维护问题:多个业务用例共用请求能力时,怎样集中处理协议变化,同时保留业务差异和已有调用方式。

这项工作的交付物是业务用例使用的 API 与 SDK。已有贡献记录包含 23 个业务类、5 个业务方向的复用,以及 20 多个存量子类的兼容要求。重构需要让这些调用继续工作,同时把后续协议调整收敛到共享实现。

复用的收益取决于什么一起变化

一条资金业务测试通常要准备前置条件、执行操作、等待状态变化,再断言结果。其中请求组装、签名和版本选择可能在不同业务里重复,而成功条件、允许的返回码和数据准备方式又各不相同。

例如,签名规则调整后,所有使用这套协议的业务都需要跟着更新。若请求代码散在用例里,就要逐处修改。但某个业务新增一种拒绝码时,又不应该让其他业务的判断一起变化。把这两种修改分开,才是这里划分请求层与业务层的原因。

当前拆分把请求能力与业务语义分成两层,用例在其上组合操作并断言:

位置处理的变化向调用方提供什么
公共请求层请求结构、签名、可复用路由和执行机制可以被业务层使用的请求能力
业务 API具体操作、参数含义、版本与返回码约定可供测试场景组合的业务动作
用例前置状态、操作组合、预期结果对业务行为的判断

判断一段逻辑放在哪层,可以看修改后谁应该受到影响。签名修正放进共享请求层,使用它的调用一起更新;业务返回码的含义留在对应 API 中,由该业务解释。用例随后组合这些动作,检查自己关心的状态变化。

兼容要求限制了重构方式

公共层重构不能只观察新接口是否可用。已有子类可能依赖参数默认值、返回对象形状、异常行为和可覆盖的方法;即使签名没有变化,行为改变也可能让旧断言失效。

以下是一个说明兼容判断的教学例子,不对应内部接口。旧用例读取业务 API 的 result.code,新请求库返回一个协议响应对象。如果业务 API 直接透传新对象,底层替换就扩散到了所有旧用例。两层结构提供了一个处理位置:在业务 API 下方适配协议表示,使业务操作继续给出原有语义。

但“原有语义”也有边界。负向测试需要读取预期的业务拒绝结果;若公共层把所有非成功返回码统一抛成异常,旧用例可能在断言之前中止。传输失败与业务拒绝必须分别考虑,不能只以返回类型一致判断兼容。

下面把这个约束写成一个独立、可运行的适配器。示例中的路径、字段和返回码均为教学定义。transport.request 表示已替换的请求库;FundsAPI.query 保留调用方使用的 Result.code,并继续通过 self.route() 调用子类扩展点。

from dataclasses import dataclass

@dataclass(frozen=True)
class Result:
    code: str
    data: dict


class FundsAPI:
    def __init__(self, transport):
        self.transport = transport

    def route(self):
        return "/v2/funds/query"

    def query(self, record_id, *, version="v2"):
        reply = self.transport.request(
            self.route(), {"record_id": record_id}, version=version
        )
        # Transport exceptions propagate; a business rejection remains data.
        body = reply["body"]
        return Result(code=body["code"], data=body["data"])

负向用例可以继续断言 result.code == "DENIED";网络超时则沿异常路径传播。这里没有给缺失的 code 补默认成功值,协议格式改变时会暴露适配错误。

兼容测试应固定旧调用者依赖的行为。用一个记录参数的传输替身,可以检查默认版本是否仍是 v2、显式传入的 v1 是否保留、旧子类覆盖的路由是否真正被调用。只断言返回对象类型一致,会漏掉这些变化。

输入或替身行为应观察到的结果能发现的回归
旧调用省略 version向传输层传入 v2默认值变化
子类覆盖 route,调用指定 v1使用旧路由和 v1基类绕过扩展点
返回 DENIED返回可断言的业务结果负向结果被统一抛成异常
传输层抛 TimeoutError调用方仍收到异常超时被伪装成业务拒绝

返回码重试把什么收进公共层

资金业务存在异步落单窗口,初次返回时状态可能尚未满足下一步检查。我将多个接口里重复的重试循环整理为按返回码声明条件的装饰器,并推广到公共层。

这一层可以集中处理重复调用的机制,但返回码是否允许再次尝试仍取决于业务含义。尤其需要区分两种操作:再次查询状态是在观察同一项业务进展;再次提交资金动作则可能创造新的副作用。

举一个独立的推演:提交已经到达服务端,但客户端没有收到响应。此时超时只说明客户端缺少结果,无法据此断定提交未发生。直接换一个业务标识重新提交,可能造成重复动作。若接口有幂等约定,需要依其约定复用标识或先查状态;没有这样的契约,通用重试装饰器无法凭自身补出安全性。

对只读查询,可以把循环单独写出来。下面只对 PENDING 继续查询;业务拒绝立即返回,传输异常直接传播,耗尽次数后抛出超时。它展示装饰器内部应有的行为,没有复刻内部 SDK 的实现。

def poll_query(query, *, attempts, sleep, interval=0.1):
    if type(attempts) is not int or attempts < 1:
        raise ValueError("attempts must be a positive integer")
    for index in range(attempts):
        result = query()
        if result.code != "PENDING":
            return result
        if index + 1 < attempts:
            sleep(interval)
    raise TimeoutError("query remained PENDING")

调用方传入 sleep=time.sleep;测试传入 list.append,就能检查等待次数而无需真实等待。PENDING → OK 应查询两次、等待一次;连续三次 PENDING 应查询三次、等待两次并失败;DENIED 应只查询一次。次数上限不能代替单次请求超时,真实请求仍需由传输层限制等待时间。

下载完整示例与兼容测试,在保存目录执行:

python sdk_contract.py

示例包含 7 项检查,覆盖上表中的兼容行为和三种轮询路径。它不连接支付服务;接入真实 SDK 时,还需使用旧调用样本和测试环境验证协议、签名及资源清理。

这项经验怎样继续迁移

这些 API 依赖具体业务与环境,移到新公司不能直接照搬。能够带走的是分析方法:找出反复变化的请求机制,明确业务差异的归属,检查既有调用契约,并在重试前判断副作用。

XAT承接执行侧的问题:当业务能力已经可以调用,怎样调度用例并判断结果是否完整。AI 测试工作流继续处理模型如何使用这些能力生成与验证用例;能力清单需要同时说明参数语义、错误行为和副作用。任职时间和职责范围见简历。