如何测试 Payment Processing 功能?
结论
测试 Payment Processing 时,不应直接用真实银行卡反复付款。更稳妥的做法是建立分层测试体系:
- 通过单元测试验证金额计算、订单状态和幂等逻辑。
- 使用支付服务商提供的 sandbox/test mode 完成接口集成测试。
- 通过端到端测试覆盖付款、回调、退款等核心流程。
- 只在生产环境中进行少量且受控的真实交易验证。
- 除了“支付成功”,还要重点测试失败、超时、重复通知和状态不一致。
支付系统的问题往往不在付款页面,而在异步回调、网络重试、重复请求、金额精度和订单状态转换。
建议的测试分层
1. 单元测试
将支付网关封装在独立接口后面,并在单元测试中使用 mock 或 fake 实现。这样不会产生真实扣款,测试也不依赖外部服务。
重点验证以下内容:
- 金额是否使用整数形式的最小货币单位,例如分,而不是浮点数。
- 币种、税费、折扣和退款金额是否正确。
- 支付请求是否包含正确的订单信息。
- 相同请求是否具有幂等性。
- 系统是否拒绝非法状态。
- 支付失败后是否保留订单并允许重试。
- 日志中是否避免记录卡号、CVV 或完整支付凭据。
type PaymentResult =
| { status: "succeeded"; transactionId: string }
| { status: "failed"; code: string };
interface PaymentGateway {
charge(input: {
orderId: string;
amount: number;
currency: string;
idempotencyKey: string;
}): Promise<PaymentResult>;
}
async function payOrder(
gateway: PaymentGateway,
order: { id: string; totalInCents: number; paid: boolean }
) {
if (order.paid) {
return { status: "already_paid" as const };
}
return gateway.charge({
orderId: order.id,
amount: order.totalInCents,
currency: "USD",
idempotencyKey: `payment:${order.id}`
});
}
测试时可以完全替换第三方支付网关:
import { expect, test, vi } from "vitest";
test("使用订单总额和稳定的幂等键发起支付", async () => {
const charge = vi.fn().mockResolvedValue({
status: "succeeded",
transactionId: "txn_test_001"
});
const gateway = { charge };
const order = {
id: "order_123",
totalInCents: 1099,
paid: false
};
await payOrder(gateway, order);
expect(charge).toHaveBeenCalledWith({
orderId: "order_123",
amount: 1099,
currency: "USD",
idempotencyKey: "payment:order_123"
});
});
这里的 1099 表示 10.99 个货币单位。核心金额运算不使用 10.99,可以减少浮点精度问题。
2. Sandbox 集成测试
主流支付服务商通常会提供 sandbox、test mode 或测试商户账户。具体名称和功能取决于服务商,应以官方文档为准。
集成测试需要连接测试环境中的真实 API,并验证:
- 身份认证和请求签名。
- 支付方式或 token 的创建。
- 授权、扣款和支付确认。
- 取消、全额退款和部分退款。
- 服务商返回的错误码。
- Webhook 签名校验。
- 本地订单与远端交易的关联关系。
不要只测试一个“成功卡号”。如果测试环境支持场景模拟,还要覆盖:
- 支付被拒绝。
- 余额不足。
- 需要额外身份验证。
- 请求超时。
- 限流或临时服务错误。
- 退款失败。
- 回调延迟或乱序。
测试数据和测试凭据必须与生产环境隔离。应用可以在启动时检查环境配置,防止测试密钥误连生产系统。
端到端测试流程
至少准备一条完整的 happy path:
- 创建订单。
- 打开结账页面。
- 使用服务商提供的测试支付方式提交付款。
- 接收并验证 Webhook。
- 将订单更新为已支付。
- 验证库存、收据或后续业务动作。
- 发起退款。
- 验证退款状态和订单状态。
浏览器跳转到“支付成功”页面,并不代表端到端测试已经成功。前端重定向可能被伪造,也可能发生在后端收到最终支付结果之前。业务上的支付成功,应以服务端验证后的支付状态为准。
Webhook 测试
Webhook 值得单独测试。回调可能重复、延迟或乱序,也可能因为处理过程中服务重启而被再次发送。
处理逻辑至少应做到:
- 先验证签名,再处理事件。
- 使用事件 ID 去重。
- 同一事件重复到达时,不会重复发货或记账。
- 未识别的事件类型不会破坏订单状态。
- 处理失败后可以安全重试。
- 快速返回响应,将耗时工作交给队列执行。
async function handleWebhook(event: PaymentEvent) {
const inserted = await eventStore.insertIfAbsent(event.id);
if (!inserted) {
return; // 重复事件已经处理
}
switch (event.type) {
case "payment.succeeded":
await orders.markPaidIfPending(
event.orderId,
event.transactionId
);
break;
case "payment.failed":
await orders.markPaymentFailedIfPending(
event.orderId,
event.reason
);
break;
default:
break;
}
}
还要专门发送两次相同事件,确认系统最终只执行一次状态变更和一次发货操作。
并发与异常场景
常规 walkthrough 很难发现并发和异常情况下的问题,因此支付测试还要包含以下场景:
- 用户连续点击两次“支付”。
- 浏览器提交后立即关闭。
- API 请求成功,但客户端在收到响应前超时。
- 两个服务实例同时处理同一回调。
- 支付已经成功,但数据库更新失败。
- 退款请求被重复提交。
- 订单取消后才收到支付成功事件。
payment.failed和payment.succeeded乱序到达。- 支付金额或币种与订单不一致。
每个场景都要明确最终状态,不能只检查某次接口调用的响应。例如,客户端超时后不应立即创建一笔没有关联的新付款,而应使用幂等键重试,或查询原交易的状态。
状态机测试
可以显式定义订单或支付状态,例如:
pending -> processing -> paid
pending -> failed
processing -> failed
paid -> partially_refunded
paid -> refunded
测试所有允许的转换,并拒绝以下危险转换:
refunded -> paid
failed -> refunded
cancelled -> paid
实际状态可能比示例复杂。设计时要根据支付服务商是否区分 authorization、capture、settlement、dispute 等阶段进行调整。
生产环境验证
Sandbox 无法完整模拟银行网络、风控、结算和真实账单。上线前后通常仍要进行少量真实交易验证,但整个过程必须受控:
- 使用允许进行真实测试的支付方式。
- 采用可以接受的小额交易。
- 测试后按照正常流程退款。
- 核对支付平台、应用数据库和财务记录。
- 确认生产 Webhook 地址和签名密钥正确。
- 确认监控与告警可用。
真实交易测试是否允许、应如何执行,取决于支付服务商、收单机构和所在地区的规则。不能将 sandbox 测试卡用于生产环境,也不应通过虚假交易模拟业务收入。
安全与合规注意事项
- 不要在自动化测试、源码、日志或截图中保存真实卡号和 CVV。
- 优先使用支付服务商托管的支付页面、SDK 或 tokenization,减少系统接触敏感卡数据的范围。
- 测试凭据和生产凭据必须分开,并由密钥管理系统提供。
- 数据库通常只保存支付 token、交易 ID、卡品牌和脱敏后的末四位等必要信息。
- 是否需要满足 PCI DSS 以及具体适用范围,应根据实际支付集成方式和合规要求确认。
- 自动化测试不得无限制调用生产支付 API。
推荐落地顺序
第一次测试支付功能时,可以按照以下顺序实施:
- 抽象
PaymentGateway,为业务逻辑编写 mock 单元测试。 - 在服务商测试环境中跑通付款和退款。
- 为 Webhook 增加签名校验、去重和重试测试。
- 补充失败、超时、并发和乱序场景。
- 建立每日运行或发布前运行的 sandbox 集成测试。
- 上线时执行一笔受控的真实交易,并核对整个链路。
- 增加支付失败率、回调积压和账务差异告警。
- 定期对账应用订单与支付服务商的交易记录。
可靠的支付测试不仅要证明“按钮可以点击”,还要确认系统在成功、失败、超时和重复通知等情况下,都不会多扣款、重复履约、丢失交易或产生无法解释的账务状态。
备注:内容仅供参考。
