mcpField
P
payment-testing

payment-testing

github.com/petrkindlmann测试与质量2221.0k 次安装6 天前更新
代码仓库

关于 payment-testing

覆盖 PSP 沙箱、3DS 验证、订阅时间推进和 Webhook 处理的支付测试技能。它帮助团队在不使用实时密钥或真实卡号的情况下验证支付,重点关注签名验证、服务端确认和幂等履约。

主要功能

  1. 01测试 PSP 沙箱支付结果
  2. 02处理嵌套 3DS 帧
  3. 03用测试时钟推进计费
  4. 04验证 Webhook 签名
  5. 05测试幂等履约
  6. 06在服务端核对支付

适用场景

  1. 01测试银行卡成功与拒付路径
  2. 02模拟订阅续费和扣款失败
  3. 03验证 Webhook 拒绝与重复处理
  4. 04确保订单仅在支付验证后标记完成

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/petrkindlmann/qa-skills --skill payment-testing

安装后在 Claude Code 中输入/payment-testing确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
multi-psp.md
playwright-3ds.md
stripe-test-cards.md
webhooks-and-clocks.md

详细介绍

支付流程的失败方式,是通用端到端测试所无法发现的:跨源 iframe 中的卡号输入框被 `page.locator` 悄然漏掉,续费要等一年才会触发,Webhook 处理程序看似“正常”,直到 Stripe 重试并将同一订单履行两次,或者浏览器伪造了重定向,订单就被标记为已支付。本技能将帮助你按支付流程的实际故障方式进行测试——使用 PSP 沙盒、真实测试卡号、嵌套的 3DS 验证、服务端测试时钟、经过签名验证的 Webhook,以及幂等的订单履行。绝不使用真实 PAN,绝不使用线上密钥。

快速导航

你需要测试……前往参考资料
成功/拒付/余额不足等结果测试卡号references/stripe-test-cards.md
弹出模态框的 3DS/SCA 验证3DS 验证references/playwright-3ds.md
数月/数年后的订阅续费测试时钟references/webhooks-and-clocks.md
将 Webhook 发送到 localhost + 验证签名Webhookreferences/webhooks-and-clocks.md
续费失败后退款支付失败references/webhooks-and-clocks.md
仅在真实支付后履行订单对账references/webhooks-and-clocks.md
Adyen/PayPal/Braintree 沙盒多 PSPreferences/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)与已部署的预览环境,会影响事件的发送方式。

核心原则

  1. 绝不使用真实银行卡或线上密钥——这是不可妥协的要求,而非偏好。 在任何环境中使用真实 PAN 都违反 Stripe《服务协议》,并使你的代码库进入 PCI 合规范围。唯一正确的做法是使用测试模式(pk_test_/sk_test_)和 Stripe 发布的测试卡号。屏蔽或加密真实卡号并不能解决问题;只有移除它才能解决。

  2. 必须在服务端确认款项,绝不能在客户端确认。 重定向、onApprove 回调或 ?status=success 查询参数都可能过早触发、被重放或伪造。只有在收到经过签名验证的 payment_intent.succeeded Webhook,并通过 API retrieve 再次确认后,才能履行订单。

  3. 解析请求正文前先验证签名。 先解析 JSON 会破坏 constructEvent 所需的原始字节。Webhook 路由应获取原始请求正文;其他所有内容都可以解析为 JSON。

  4. 计费中的时间由服务端构建。 Stripe 的计费引擎运行在 Stripe 的服务器上。在测试进程中伪造时钟不会产生任何影响。请使用测试时钟。

  5. 假设每个 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)订阅——它会附加到客户,但会在后续扣款时失败,这正是续订所需的行为。附加时就被拒绝的卡片无法保存,因此无法模拟续订失败。

使用测试时钟驱动生命周期:

  1. 使用先附加、后失败的卡片为客户订阅(使用测试时钟)。
  2. 将时钟 advance 到续订日期之后 → Stripe 尝试扣款。
  3. 扣款失败 → Stripe 发出 invoice.payment_failed,且订阅变为 past_due。断言两者均成立。
  4. 通过调用 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.succeeded Webhook 后,订单才会变为 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 的差异。

评论

支持 Markdown