ELL3N HSU
← Articles

Payments testing SDK: sharing requests without erasing business differences

Use multi-business reuse and existing subclass compatibility to explain request layering, calling contracts, and the limits of return-code retries.

During my vendor assignment to WeChat Pay, I contributed to testability APIs for delegated deductions and merchant funds, and led a refactor of the merchant-funds testing SDK. This account examines a maintenance question: how can several business tests share request behavior while retaining their differences and existing calling conventions?

The deliverables were APIs and an SDK used by business tests. Existing contribution records describe reuse across 23 business classes and five business areas, with compatibility requirements for more than 20 existing subclasses. The refactor needed to preserve those calls while concentrating subsequent protocol changes in shared code.

Reuse depends on what changes together

A funds test typically prepares state, performs an operation, waits for a state transition, and asserts the result. Request construction, signing, and version selection may recur across business areas. Success criteria, allowed return codes, and test data preparation can differ.

For example, a signing change should reach every business using that protocol. If request code is scattered through tests, each copy needs updating. A new rejection code in one business should have a narrower effect. Separating those two kinds of change is the reason for having request and business layers.

The refactor separates request capabilities from business semantics, with tests composing operations above them:

LocationChanges it handlesWhat callers receive
Shared request layerRequest structures, signing, reusable routing and execution mechanismsRequest capabilities that business APIs can use
Business APIOperations, parameter meaning, versions, and return-code conventionsBusiness actions that tests can compose
Test casePreconditions, operation combinations, and expected outcomesA judgment about business behavior

To place a piece of logic, ask which callers should change with it. A signing fix belongs in the shared request layer so its callers receive the update together. The meaning of a return code stays with the relevant business API. Tests then compose those actions and check the state changes that matter to their scenarios.

Compatibility constrains the refactor

Checking that the new interface works is insufficient. Existing subclasses may depend on defaults, return shapes, exception behavior, or overridable methods. Unchanged signatures can still conceal behavior changes that invalidate old assertions.

Consider a teaching example, not an internal interface. An old test reads result.code from a business API, while a new request library returns a protocol-specific response. Passing that object straight through spreads the low-level replacement to all old tests. Two layers provide a place to adapt the representation while preserving the business operation’s meaning.

That meaning includes failure behavior. A negative test may need to inspect an expected business rejection. If the shared layer converts every non-success code into an exception, the old test may stop before its assertion. Transport failure and business rejection therefore require separate treatment; matching return types alone does not establish compatibility.

The following runnable adapter makes that constraint concrete. Routes, fields, and codes are teaching definitions. transport.request represents the replacement request library. FundsAPI.query keeps Result.code and continues to call the subclass extension point through 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"])

A negative test can still assert result.code == "DENIED"; a transport timeout remains an exception. A missing code is not replaced with a successful default, so a protocol mismatch remains visible.

A recording transport fake can check the default version, an explicitly supplied version, and whether the subclass route is actually invoked. Checking only the return type would miss these changes.

Input or fake behaviorExpected observationRegression it catches
Old caller omits versionTransport receives v2Changed default
Subclass overrides route and caller supplies v1Old route and v1 are usedBase class bypasses extension point
Backend returns DENIEDBusiness result remains available for assertionsRejection converted into an exception
Transport raises TimeoutErrorException reaches callerTimeout disguised as business rejection

What return-code retries centralize

Funds operations have asynchronous recording windows: an initial result may arrive before the state needed by the next check is ready. I extracted repeated loops into a decorator whose retry conditions are declared through return codes, then promoted it into the shared layer.

The mechanism can be centralized, but the meaning that permits another attempt remains business-specific. Querying state again observes an operation’s progress; submitting the operation again may create another side effect.

For an independent reasoning example, suppose a submission reaches the server but the response never reaches the client. A timeout establishes missing client knowledge, not that the submission did not happen. Submitting again under a new identifier may duplicate the action. An idempotency contract may require reusing an identifier or querying state first. Without that contract, a generic retry decorator cannot supply safety on its own.

For a read-only query, the loop can be expressed separately. This version retries only PENDING, returns business rejection immediately, propagates transport exceptions, and raises on exhaustion. It illustrates behavior that a decorator could contain; it does not reproduce the internal 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")

A caller supplies sleep=time.sleep; tests supply list.append to inspect waits without sleeping. PENDING → OK takes two queries and one wait. Three consecutive PENDING results take three queries and two waits, then fail. DENIED takes one query. A bounded attempt count does not replace a per-request transport timeout.

Download the complete example and compatibility tests, then run it from the download directory:

python sdk_contract.py

Its seven checks cover the compatibility cases above and the three polling paths. It makes no payments-service requests. A real SDK migration still needs old caller fixtures and environment checks for protocols, signing, and cleanup.

What transfers to other work

These APIs depend on a specific business and environment. The transferable part is the method: identify request mechanisms that change repeatedly, give business differences an explicit owner, check existing calling contracts, and examine side effects before retrying.

XAT continues on the execution side: once business capabilities can be called, how should tests be scheduled and their results checked for completeness? The AI testing workflow extends this toward models using those capabilities to generate and validate tests. Capability descriptions need parameter semantics, error behavior, and side effects. Employment dates and responsibilities are in the résumé.