如何对 payment gateway integration code 进行 unittest?
明确结论
是的。单元测试应该通过 mock 模拟支付网关的不同响应,确认业务代码能正确处理支付成功、失败、超时、异常和重复通知等情况。
但 mock 验证的只是自己的代码如何应对网关行为,无法证明真实支付网关可以正常工作。完整的支付集成通常需要三层测试:
- 单元测试:mock 网关接口,快速覆盖各个业务分支。
- 集成测试:连接支付服务商提供的 sandbox,检查请求格式、签名和响应解析。
- 少量端到端测试:检查跳转、回调和订单状态更新等完整流程。
先隔离第三方支付代码
不要让订单业务直接依赖某个支付服务商的 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 测试真实集成,再以少量端到端测试确认完整支付流程。
备注:内容仅供参考。
