PAYATHON 2026

如何对 payment gateway integration code 进行 unittest?

支付老李

明确结论

是的。单元测试应该通过 mock 模拟支付网关的不同响应,确认业务代码能正确处理支付成功、失败、超时、异常和重复通知等情况。

但 mock 验证的只是自己的代码如何应对网关行为,无法证明真实支付网关可以正常工作。完整的支付集成通常需要三层测试:

  1. 单元测试:mock 网关接口,快速覆盖各个业务分支。
  2. 集成测试:连接支付服务商提供的 sandbox,检查请求格式、签名和响应解析。
  3. 少量端到端测试:检查跳转、回调和订单状态更新等完整流程。

先隔离第三方支付代码

不要让订单业务直接依赖某个支付服务商的 SDK。先定义一个稳定的网关接口,再由适配器调用真实 SDK。

type PaymentResult =
  | {
      status: "succeeded";
      transactionId: string;
  }
  | {
      status: "declined";
      reason: string;
  }
  | {
      status: "pending";
      transactionId: string;
  };

interface PaymentGateway {
  charge(input: {
    orderId: string;
    amount: number;
    currency: string;
    paymentToken: string;
  }): Promise<PaymentResult>;
}

业务服务只依赖 PaymentGateway:

class CheckoutService {
  constructor(
    private readonly gateway: PaymentGateway,
    private readonly orders: OrderRepository
  ) {}

  async pay(orderId: string, paymentToken: string): Promise<void> {
    const order = await this.orders.getById(orderId);

    if (order.status === "paid") {
      return;
    }

    const result = await this.gateway.charge({
      orderId: order.id,
      amount: order.amount,
      currency: order.currency,
      paymentToken
    });

    switch (result.status) {
      case "succeeded":
        await this.orders.markPaid(order.id, result.transactionId);
        return;

      case "declined":
        await this.orders.markPaymentFailed(order.id, result.reason);
        return;

      case "pending":
        await this.orders.markPaymentPending(order.id, result.transactionId);
        return;
    }
  }
}

采用这种结构后,单元测试不需要发起真实网络请求,也不会受到服务商测试环境可用性的影响。

使用 mock 覆盖主要响应

下面以 Jest 为例。具体项目还应根据支付服务商实际支持的状态补充测试,不要自行假设字段和值。

支付成功

test("marks the order as paid when payment succeeds", async () => {
  const gateway: PaymentGateway = {
    charge: jest.fn().mockResolvedValue({
      status: "succeeded",
      transactionId: "txn_test_123"
    })
  };

  const orders = {
    getById: jest.fn().mockResolvedValue({
      id: "order_1",
      amount: 1000,
      currency: "CNY",
      status: "unpaid"
    }),
    markPaid: jest.fn(),
    markPaymentFailed: jest.fn(),
    markPaymentPending: jest.fn()
  };

  const service = new CheckoutService(gateway, orders);

  await service.pay("order_1", "payment_token");

  expect(gateway.charge).toHaveBeenCalledWith({
    orderId: "order_1",
    amount: 1000,
    currency: "CNY",
    paymentToken: "payment_token"
  });

  expect(orders.markPaid).toHaveBeenCalledWith(
    "order_1",
    "txn_test_123"
  );
});

测试不能只检查最终状态,还要确认传给网关的金额、币种和订单号是否正确。否则,代码即使能处理成功响应,也可能向支付网关提交错误的数据。

支付被拒绝

test("marks the payment as failed when it is declined", async () => {
  const gateway: PaymentGateway = {
    charge: jest.fn().mockResolvedValue({
      status: "declined",
      reason: "card_declined"
    })
  };

  const orders = createOrderRepositoryMock({
    id: "order_1",
    amount: 1000,
    currency: "CNY",
    status: "unpaid"
  });

  const service = new CheckoutService(gateway, orders);

  await service.pay("order_1", "payment_token");

  expect(orders.markPaymentFailed).toHaveBeenCalledWith(
    "order_1",
    "card_declined"
  );
  expect(orders.markPaid).not.toHaveBeenCalled();
});

网络超时或 SDK 异常

超时和明确拒付是两种不同的情况。网关超时时,支付可能已经完成,只是系统没有收到响应。此时不应直接把订单标记为失败,更不能立即再次扣款。

test("does not mark the order as paid when the gateway times out", async () => {
  const gateway: PaymentGateway = {
    charge: jest.fn().mockRejectedValue(new Error("gateway timeout"))
  };

  const orders = createOrderRepositoryMock({
    id: "order_1",
    amount: 1000,
    currency: "CNY",
    status: "unpaid"
  });

  const service = new CheckoutService(gateway, orders);

  await expect(
    service.pay("order_1", "payment_token")
  ).rejects.toThrow("gateway timeout");

  expect(orders.markPaid).not.toHaveBeenCalled();
  expect(orders.markPaymentFailed).not.toHaveBeenCalled();
});

生产代码可以暂时将这类订单保留为 pending 或 unknown,再通过 webhook 或主动查询确认结果。具体采用哪种策略,取决于支付服务商提供的接口能力。

如果流程依赖跳转和回调

将用户跳转到第三方网站后,不能仅凭浏览器返回页面判断支付成功。用户可能关闭页面、修改请求参数,也可能在支付完成前返回。

订单状态通常应根据服务端 webhook 更新,或者由服务端使用交易编号查询支付结果。回调处理也要单独测试:

test("rejects a webhook with an invalid signature", async () => {
  const verifier = {
    verify: jest.fn().mockReturnValue(false)
  };

  const handler = new PaymentWebhookHandler(verifier, orders);

  await expect(
    handler.handle('{"event":"payment.succeeded"}', "bad-signature")
  ).rejects.toThrow("invalid webhook signature");

  expect(orders.markPaid).not.toHaveBeenCalled();
});

回调测试至少需要覆盖:

  • 签名有效和签名无效;
  • 请求体损坏或缺少必要字段;
  • 支付成功、失败、取消和待确认事件;
  • 未知事件类型;
  • 找不到对应订单;
  • 回调重复到达;
  • 回调乱序到达;
  • 金额或币种与本地订单不一致。

必须测试幂等性

支付服务商可能重复发送同一个 webhook,客户端也可能在超时后重复提交。业务代码必须保证同一笔支付不会被重复记账、重复发货或重复发放权益。

test("processes the same successful event only once", async () => {
  const event = {
    id: "evt_123",
    orderId: "order_1",
    transactionId: "txn_123",
    status: "succeeded"
  };

  await handler.handleVerifiedEvent(event);
  await handler.handleVerifiedEvent(event);

  expect(orders.markPaid).toHaveBeenCalledTimes(1);
});

实际实现时,可以为网关事件 ID、交易 ID 或业务幂等键建立唯一约束。只在内存中检查事件是否处理过,通常无法应对并发请求。

不要只 mock 已经转换过的结果

业务层可以 mock PaymentGateway,但网关适配器也需要测试。支付服务商可能返回特定错误码、HTTP 状态码或嵌套 JSON,适配器需要将这些响应转换为内部统一结果。

适配器测试可以拦截 HTTP 请求并返回固定响应,主要检查:

  • 请求地址和 HTTP method;
  • Authorization、签名等请求头;
  • 金额单位是否正确,例如元与分的换算;
  • 币种、订单号和幂等键;
  • 响应字段解析;
  • 非 2xx 响应;
  • 无效 JSON、缺失字段和未知状态;
  • SDK 抛出的异常是否被正确转换。

测试样本最好取自服务商公开文档、sandbox 的实际响应或经过脱敏的日志。如果只是手工编造一个过度简化的成功对象,很容易漏掉真实协议中的重要细节。

sandbox 测试仍然不可少

单元测试中的 mock 总会按预设方式工作,真实服务却可能出现以下差异:

  • 请求字段名称或数据类型不符;
  • 签名算法、时间戳或字符编码错误;
  • SDK 配置错误;
  • webhook 无法访问;
  • 重定向 URL 配置不正确;
  • sandbox 与生产环境使用不同的凭证或域名。

因此,还需要使用服务商提供的测试卡号、测试账户和 sandbox 场景进行集成测试。能够模拟哪些状态,应以所用支付服务商的官方文档为准。

安全注意事项

测试中不要使用真实信用卡数据,也不要将卡号、CVV、API secret 或 webhook secret 写入源码和测试 fixture。

如果信用卡信息由第三方托管页面或前端组件直接采集,自己的服务器通常只会接收 token 或支付结果。这样可以缩小敏感数据的处理范围,但具体合规责任仍取决于实际架构、所在地区和支付服务商的要求。不能因为使用了第三方页面,就认定系统已经满足 PCI DSS。

比较合理的测试组合是:用大量单元测试覆盖业务分支,通过适配器测试检查协议转换,用 sandbox 测试真实集成,再以少量端到端测试确认完整支付流程。

备注:内容仅供参考。