关于 payment-testing
覆盖 PSP 沙箱、3DS 验证、订阅时间推进和 Webhook 处理的支付测试技能。它帮助团队在不使用实时密钥或真实卡号的情况下验证支付,重点关注签名验证、服务端确认和幂等履约。
主要功能
- 01测试 PSP 沙箱支付结果
- 02处理嵌套 3DS 帧
- 03用测试时钟推进计费
- 04验证 Webhook 签名
- 05测试幂等履约
- 06在服务端核对支付
适用场景
- 01测试银行卡成功与拒付路径
- 02模拟订阅续费和扣款失败
- 03验证 Webhook 拒绝与重复处理
- 04确保订单仅在支付验证后标记完成
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ petrkindlmann/ qa-skills --skill payment-testing安装后在 Claude Code 中输入/payment-testing确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/multi-psp.mdplaywright-3ds.mdstripe-test-cards.mdwebhooks-and-clocks.md详细介绍
快速导航
| 你需要测试…… | 前往 | 参考资料 |
|---|---|---|
| 成功/拒付/余额不足等结果 | 测试卡号 | references/stripe-test-cards.md |
| 弹出模态框的 3DS/SCA 验证 | 3DS 验证 | references/playwright-3ds.md |
| 数月/数年后的订阅续费 | 测试时钟 | references/webhooks-and-clocks.md |
| 将 Webhook 发送到 localhost + 验证签名 | Webhook | references/webhooks-and-clocks.md |
| 续费失败后退款 | 支付失败 | references/webhooks-and-clocks.md |
| 仅在真实支付后履行订单 | 对账 | references/webhooks-and-clocks.md |
| Adyen/PayPal/Braintree 沙盒 | 多 PSP | references/multi-psp.md |
发现问题
首先,检查项目根目录中的 .agents/qa-project-context.md,跳过其中已有答案的内容(PSP、技术栈、测试框架、现有固件)。然后确认:
- 使用哪个 PSP?是 Stripe 吗? 此处默认使用 Stripe。其他 PSP 也遵循相同模式,但有各自的沙盒卡号/账户——请参阅多 PSP。
- 是单次支付、订阅,还是两者都有? 订阅会涉及测试时钟、催款流程和
invoice.*生命周期;单次支付则不会。 - 是否涉及 SCA/3DS? 欧盟/英国的银行卡支付流程几乎总会触发验证。如果涉及,你需要使用嵌套 iframe 模式,而不是普通定位器。
- 目前是在 Webhook 还是重定向时履行订单? 如果在
return_url时履行订单,这就是需要测试的缺陷——必须等到经过验证的 Webhook 到达后才能履行订单。 - 测试中 Webhook 处理程序运行在哪里? 本地环境(
stripe listen)与已部署的预览环境,会影响事件的发送方式。
核心原则
-
绝不使用真实银行卡或线上密钥——这是不可妥协的要求,而非偏好。 在任何环境中使用真实 PAN 都违反 Stripe《服务协议》,并使你的代码库进入 PCI 合规范围。唯一正确的做法是使用测试模式(
pk_test_/sk_test_)和 Stripe 发布的测试卡号。屏蔽或加密真实卡号并不能解决问题;只有移除它才能解决。 -
必须在服务端确认款项,绝不能在客户端确认。 重定向、
onApprove回调或?status=success查询参数都可能过早触发、被重放或伪造。只有在收到经过签名验证的payment_intent.succeededWebhook,并通过 APIretrieve再次确认后,才能履行订单。 -
解析请求正文前先验证签名。 先解析 JSON 会破坏
constructEvent所需的原始字节。Webhook 路由应获取原始请求正文;其他所有内容都可以解析为 JSON。 -
计费中的时间由服务端构建。 Stripe 的计费引擎运行在 Stripe 的服务器上。在测试进程中伪造时钟不会产生任何影响。请使用测试时钟。
-
假设每个 Webhook 都会收到多次。 Stripe 会重试。必须在持久化存储中使用
event.id作为幂等键;内存中的集合不算幂等。
Stripe 测试卡和结果
使用能确定性地产生相应结果的卡片来触发每种结果。你最常用的四张卡:
| PAN | 结果 | 代码 |
|---|---|---|
4242424242424242 | 成功 | — |
4000000000000002 | 被拒绝 | card_declined (generic_decline) |
4000000000009995 | 被拒绝 | insufficient_funds |
4000000000003220 | 始终触发 3DS 验证 | — |
4000000000000341 | 可附加,但在扣款时失败 | card_declined |
在被测应用中使用测试密钥(pk_test_…/sk_test_…),并在设置时断言这一点。
不要使用 4111111111111111 ——它是通用的 Braintree/PayPal 时代 Luhn 数字,并非 Stripe 测试卡,也不会确定性地导致拒绝。
卡片输入框位于跨源 Stripe iframe 中,因此请通过 frameLocator 填写,绝不要直接使用
page.locator。冒烟测试可断言 UI 文案;更稳健的做法是断言服务端 paymentIntents.retrieve 返回的
last_payment_error.decline_code。成功、card_declined 和 insufficient_funds 的完整 Playwright 测试:
references/stripe-test-cards.md。
3DS / SCA:嵌套 iframe 验证
这是最难做对的部分。Stripe 3DS 验证挑战是嵌套在 Stripe 模态框 iframe 内的 iframe ——单个
frameLocator 无法访问它。将外层和内层的 frameLocator 链接起来,然后点击完成验证。
以下操作为何会失败:
page.locator('#card')→ 输入框位于跨源页面;定位器无法匹配任何内容。page.frames()[1]→ Stripe 添加或重新排序框架时,框架索引会变化。绝不要按索引选择 框架。await page.waitForTimeout(5000)→ 猜测验证挑战的持续时间。应等待目标元素。
正确的结构(完整测试,包括验证失败的变体,见
references/playwright-3ds.md):
// 3DS-required card so the challenge always appears.
await card.getByPlaceholder('Card number').fill('4000000000003220');
await page.getByRole('button', { name: /pay/i }).click();
// Nested: outer Stripe challenge frame → inner ACS frame. One frameLocator is not enough.
const inner = page
.frameLocator('iframe[name^="__privateStripeFrame"]')
.frameLocator('iframe#challengeFrame, iframe[name="acsFrame"]');
await inner.getByRole('button', { name: /complete authentication|complete|authorize/i }).click();
await expect(page).toHaveURL(/\/success/); // assert the succeeded state
await expect(page.getByText(/payment succeeded/i)).toBeVisible();
4000002760003184 是用于 setup-intent / 首次使用流程的备用 SCA 卡;在需要一次性付款 3DS 卡的场景中,评估和文档也接受它。
测试时钟:服务端时间旅行
无需等待一年即可测试年度续订,方法是使用 Stripe 测试时钟——一种
服务端机制。客户端模拟(jest.useFakeTimers、sinon、模拟 Date)对 Stripe 的计费引擎
不起作用。
务必遵守以下规则:
- 在
frozen_time创建时钟,然后在创建客户时通过test_clock: clock.id关联客户。之后无法将现有客户关联到时钟。 testHelpers.testClocks.advance只能向前移动时间——无法倒退。每次最多推进两个计费周期。- 推进时间后,轮询时钟直至状态为
ready,然后断言续订发票和 Webhook。
const clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000), name: 'annual-renewal',
});
const customer = await stripe.customers.create({ test_clock: clock.id /* … */ });
// …create subscription, then advance ~12 months forward:
await stripe.testHelpers.testClocks.advance(clock.id, { frozen_time: oneYearLater });
创建、推进时间和断言的完整流程:references/webhooks-and-clocks.md(第 4 节)。
Webhooks:本地投递、签名与幂等性
本地投递。 不要用 ngrok 暴露你的端点,也不要轮询 API 查询状态。stripe listen 会原生地将测试事件通过隧道转发到 localhost;stripe trigger 可按需触发事件:
stripe listen --forward-to localhost:3000/webhooks # prints whsec_… ONCE at startup
stripe trigger payment_intent.succeeded
将该 whsec_… 复制到 STRIPE_WEBHOOK_SECRET。它是签名密钥,与 STRIPE_SECRET_KEY(sk_test_…)是不同的值,切勿混淆。
签名验证。 在任何全局 express.json() 之前,将 express.raw 挂载到 webhook 路由上,这样 constructEvent 才能获取原始请求体。伪造或篡改的事件必须被拒绝,并返回 400;切勿手动编写 === signature 字符串比较。
app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res, next) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return res.status(400).send(`Webhook Error: ${(err as Error).message}`); // SignatureVerificationError
}
try { return await handleEvent(event, res); }
catch (err) { return next(err); } // processing failures remain retryable 5xx
});
app.use(express.json()); // everything else, after the webhook route
幂等性。 Stripe 会重试投递,因此同一个 event.id 可能会到达两次。请求端幂等键(用于出站 API 调用)无法对传入的 webhook 去重。将 event.id 存储起来并添加 UNIQUE 约束,且要与履约操作在同一个数据库事务中完成。只有提交后才返回 200。如果在插入标记后处理失败,则回滚两项更改并返回可重试的 5xx。否则,重试时会发现该标记,并悄然丢弃尚未付款/履约的订单。请使用 references/webhooks-and-clocks.md 中与事务绑定的处理程序;内存中的集合无法在重启后保留。
对于外部副作用,请提交一个 outbox 任务,并确保其消费者具备幂等性。还要强制使用业务操作键,例如订单/付款 ID:两个不同的事件 ID 可能描述同一项操作。不要声称可以实现恰好一次投递。
签名测试(有效签名通过,伪造签名 → 400)和“重复投递同一事件两次,断言只履约一次”的幂等性测试位于 references/webhooks-and-clocks.md(第 2–3 节)。还应测试去重插入后的故障、重启后的重试、并发重复事件、同一笔付款对应不同事件 ID,以及乱序投递。仅进行顺序重复测试无法覆盖崩溃窗口。
付款失败:催收与退款
要端到端测试经常性扣款失败,请使用 4000000000000341(SDK 令牌 pm_card_chargeCustomerFail)订阅——它会附加到客户,但会在后续扣款时失败,这正是续订所需的行为。附加时就被拒绝的卡片无法保存,因此无法模拟续订失败。
使用测试时钟驱动生命周期:
- 使用先附加、后失败的卡片为客户订阅(使用测试时钟)。
- 将时钟
advance到续订日期之后 → Stripe 尝试扣款。 - 扣款失败 → Stripe 发出
invoice.payment_failed,且订阅变为past_due。断言两者均成立。 - 通过调用
refunds.create退款来处理(触发charge.refunded)——不要通过删除订阅来“修复”它。
完整驱动程序见 references/webhooks-and-clocks.md(第 5 节)。
对账:通过 Webhook 履约,而非重定向
仅在签名验证通过的 payment_intent.succeeded webhook 事件经 API 再次确认后,才将订单标记为已付款;绝不能在 return_url 重定向或客户端成功标志出现时就标记,也不要通过休眠后轮询来确认。
if (event.type === 'payment_intent.succeeded') {
const verified = await stripe.paymentIntents.retrieve(event.data.object.id);
if (verified.status === 'succeeded' && verified.amount_received === expected) {
await markOrderPaid(verified.metadata.orderId); // fulfillment happens HERE
}
}
对账测试会断言:重定向后订单仍为 pending,只有在验证通过的 webhook 到达后才变为 paid:references/webhooks-and-clocks.md(第 6 节)。
多 PSP:Adyen、PayPal、Braintree
Stripe 测试卡在其他 PSP 上不可用。每家 PSP 都有自己的沙盒卡和沙盒买家账户。沿用 Stripe 测试的结构,替换为对应 PSP 的沙盒值。切勿重复使用 Stripe PAN 或生产密钥。
- Adyen — 使用专属测试卡(例如,
4212345678910014用于 3DS2);许多拒绝交易由交易金额触发(.13会被拒绝,.51会被转介),而非卡本身。事件会以 HMAC 签名通知的形式到达。 - PayPal — 使用沙盒买家账户(沙盒个人邮箱/密码)登录,而非使用卡片。通过 Orders API/webhook 在服务端确认,不要依赖客户端的
onApprove。 - Braintree — 通过 Drop-in UI/Hosted Fields 使用专属沙盒测试卡号码;交易结果由金额决定,卡号则用于验证。
保持不变的是:使用独立的测试/沙盒凭据、不使用真实卡片,并且仅在服务端验证事件/通知后履行订单。详情见:references/multi-psp.md。
反模式
1. 使用 4111111111111111
这个通过 Luhn 校验的号码是 Braintree/PayPal 时代的通用 PAN,不是 Stripe 测试卡。成功场景使用 4242424242424242,拒绝场景使用对应的卡号(4000000000000002、4000000000009995)。
2. 把银行卡输入框当作普通输入框处理
page.locator('#card-number') 会悄无声息地匹配不到任何内容,因为该字段位于跨源 iframe 中。请使用 frameLocator。
3. 按索引选择 iframe
page.frames()[1] 会在 Stripe 重新排列 iframe 的瞬间失效。请根据稳定的名称前缀匹配 iframe(iframe[name^="__privateStripeFrame"]),并通过链式 frameLocator 定位嵌套的 3DS 验证挑战。
4. 使用 waitForTimeout 来“等待验证挑战”
在运行缓慢的 CI 中容易不稳定,在运行快速的 CI 中则会浪费时间。请等待元素(expect(...).toBeVisible()/自动等待的定位器操作),而不是等待固定时长。
5. 使用客户端时间模拟处理账单
jest.useFakeTimers/sinon/模拟 Date 无法推进 Stripe 服务端的账单时间。请使用测试时钟。
6. 使用 ngrok 或轮询来处理本地 webhook
stripe listen --forward-to localhost:3000/webhooks 可原生转发事件;stripe trigger 可触发事件。无需公共隧道,也无需轮询状态。
7. 在验证签名前解析请求体
如果全局 express.json() 中间件先于 webhook 路由执行,就会破坏 constructEvent 所需的原始请求体,导致验证永远无法通过。请先在 webhook 路由上挂载 express.raw。
8. 混淆请求幂等键与 webhook 去重,或使用内存集合
出站幂等键无法对入站 webhook 去重;内存中的 Set 会在重启后消失。请通过 UNIQUE 约束持久化 event.id。
9. 根据重定向/客户端成功标志履行订单
return_url 可能过早触发、被重放或伪造。只有通过验证的 payment_intent.succeeded webhook 才能触发订单履行。
10. 通过删除订阅来“修复”续费失败
正确的处理方式是通过 refunds.create 退款,同时保留 invoice.payment_failed → past_due 的催款生命周期,使其完整可测试。
11. 认为“只在 CI 中”使用真实卡也合理
无论运行环境如何,硬编码真实 PAN 都违反 PCI/合规要求。正确做法是在测试模式下使用测试卡,并从代码仓库和 git 历史中移除密钥,同时轮换任何已泄露的密钥。对其进行遮蔽或加密也不能使这种做法变得可接受。
验证
stripe listen --forward-to localhost:3000/webhooks会打印一个whsec_…,并在运行stripe trigger payment_intent.succeeded时显示收到的事件。- 使用
4000000000003220运行 3DS 测试,会进入并点击完成验证按钮(如果找不到嵌套 frame,测试会明确报错,而不是悄无声息地失败)。 - 签名测试:被篡改的
Stripe-Signature会返回 400;来自generateTestHeaderString的标头会返回 200。 - 幂等性测试:同一个
event.id投递两次,只会履行订单一次。 - 对测试套件运行
grep -rE 'pk_live|sk_live|4111111111111111',不会返回任何结果。
完成标准
- Checkout 套件涵盖成功(
4242424242424242)、card_declined(4000000000000002)和insufficient_funds(4000000000009995),并分别断言 对应结果,使用pk_test_/sk_test_密钥。 - 3DS 测试会填写
4000000000003220,通过链式frameLocator进入嵌套挑战框架, 点击 完成身份验证,并断言状态为 succeeded——不得使用frames()[index]或waitForTimeout。 - 订阅续订测试使用 Stripe 测试时钟(
testHelpers.testClocks.create+advance,仅向前推进,并在创建时关联客户),而非任何客户端时间模拟。 - 通过
stripe listen --forward-to/stripe trigger接收本地 Webhook,并将whsec_…配置到STRIPE_WEBHOOK_SECRET(与STRIPE_SECRET_KEY不同)。 - Webhook 处理程序会在解析之前,使用
constructEvent对原始请求体验证签名;伪造事件会返回 400,并有测试验证此行为。 - 通过以 UNIQUE 约束持久化
event.id来确保幂等性;重复投递会返回 200,且只完成一次,并有测试验证此行为。 - 失败续订测试通过测试时钟触发
invoice.payment_failed→past_due→refunds.create,并使用先关联、再触发失败的卡号4000000000000341。 - 对账测试确认只有在收到经过验证的
payment_intent.succeededWebhook 后,订单才会变为paid(通过paymentIntents.retrieve再次检查),而不是在重定向时变为paid。 grep -rE 'pk_live|sk_live|4111111111111111'在测试套件中找不到任何正式环境密钥或禁用的 PAN。(仅扫描[0-9]{16}会将每张合法测试卡都误判为匹配项——应匹配正式环境密钥前缀和禁用的4111…卡号,而不是所有 16 位数字字符串。)
相关技能
- api-testing — 通用 REST/GraphQL 端点测试、架构验证,以及非支付端点的身份验证流程。当目标不是 PSP Checkout/Webhook 时,请参考此技能。
- playwright-automation — Page Object Model、测试夹具,以及本技能中的 3DS 流程所依赖的通用浏览器 E2E 机制。
- compliance-testing — PCI-DSS、GDPR 和法规审计工作。本技能通过使用测试卡,让你不受 PCI 范围约束;正式合规审计请参考此技能。
- test-data-management — 创建客户、订阅和测试夹具;管理本技能创建的测试时钟关联客户。
- qa-project-context — 通用的首选起点:所有上述问题都应遵循其中的 PSP、技术栈和测试夹具惯例。
参考文件(位于 references/)
- stripe-test-cards.md — 完整的测试卡目录,包含拒绝代码,以及 Playwright 成功、拒绝和余额不足测试。
- playwright-3ds.md — 嵌套 iframe 的 3DS 挑战处理、成功和失败方案,以及选择器说明。
- webhooks-and-clocks.md —
stripe listen/trigger、原始请求体签名验证、基于event.id的幂等性、测试时钟、失败续订催收与退款,以及对账。 - multi-psp.md — Adyen、PayPal 和 Braintree 沙盒方案,以及与 Stripe 的差异。