payment-testing
Test payment and checkout flows end to end against PSP sandboxes — Stripe first, with the general pattern for Adyen/Braintree/PayPal. Covers Stripe test-mode card numbers and their decline codes, the 3DS/SCA challenge flow and its nested-iframe handling in Playwright, test clocks for subscription/bi
By petrkindlmann · 571 installs
npx skills add petrkindlmann/qa-skills --skill payment-testing
Source repository · Upstream listing
<objective
Payment flows fail in ways generic E2E tests miss: a card field in a cross origin iframe
that page.locator silently never reaches, a renewal that won't trigger for a year, a
webhook handler that "works" until Stripe retries and fulfills an order twice, an order
marked paid on a redirect that the browser forged. This skill makes you test payments the
way they actually break — against PSP sandboxes, with the real test cards, the nested 3DS
challenge, server side test clocks, signature verified webhooks, and idempotent
fulfillment. Never a real PAN, never a live key.
</objective
Quick Route
You need to test… Go to Reference
Success / decline / insufficient funds outcomes [Test cards]( stripe test cards and outcomes) references/stripe test cards.md
A 3DS/SCA challenge that pops a modal [3DS challenge]( 3ds sca the nested iframe challenge) references/playwright 3ds.md
A subscription renewal months/years out [Test clocks]( test clocks server side time travel) references/webhooks and clocks.md
Webhooks reaching localhost + signatures [Webhooks]( webhooks local delivery signatures idempotency) references/webhooks and clocks.md
A failed renewal then a refund [Failed payments]( failed payments dunning and refunds) references/webhooks and clocks.md
Fulfillment only after real payment [Reconciliation]( reconciliation fulfill on the webhook not the redirect) references/webhooks and clocks.md
Adyen / PayPal / Braintree sandboxes [Multi PSP]( multi psp adyen paypal braintree) references/multi psp.md
Discovery Questions
First, check .agents/qa project context.md in the project root and skip anything it
already answers (PSP, stack, test framework, existing fixtures). Then clarify:
Which PSP, and is it Stripe? Stripe is the default here. Other PSPs share the pattern
but have their own sandbox cards/accounts — see [Multi PSP]( multi psp adyen paypal braintree).
One time payments, subscriptions, or both? Subscriptions pull in test clocks, dunning,
and the invoice. lifecycle; one time payments don't.
Is SCA/3DS in play? EU/UK card flows almost always challenge. If yes you need the
nested iframe pattern, not plain locators.
Do you fulfill on a webhook or on the redirect today? If fulfillment happens on
return url , that's the bug to test for — fulfillment must wait for the verified webhook.
Where does the webhook handler run in tests? Local ( stripe listen ) vs a deployed
preview env changes how you deliver events.
Core Principles
1. Never touch a real card or a live key — and this is non negotiable, not a preference.
Real PANs in any environment violate Stripe's Services Agreement and drag your repo into
PCI scope. Test mode ( pk test / sk test ) with Stripe's published test cards is the
only correct answer. Masking or encrypting a real number does not fix it; removing it
does.
2. Money is confirmed server side, never client side. A redirect, an onApprove
callback, or a ?status=success query param can be premature, replayed, or forged.
Fulfill an order only after a signature verified payment intent.succeeded webhook,
re confirmed with an API retrieve .
3. Verify the signature before you parse the body. Parsing JSON first destroys the raw
bytes constructEvent needs. The webhook route gets the raw body; everything else can
parse JSON.
4. Time is a server side construct for billing. Stripe's billing engine runs on
Stripe's servers. Faking the clock in your test process changes nothing. Use test clocks.
5. Assume every webhook is delivered more than once. Stripe retries. Idempotency keyed
on event.id in durable storage is mandatory; an in memory set is not idempotency.
Stripe Test Cards and Outcomes
Drive each outcome with the card that deterministically produces it. The four you need most:
PAN Outcome Code
4242424242424242 Succeeds —
4000000000000002 Declined card declined (generic decline)
4000000000009995 Declined insufficient funds
4000000000003220 3DS always challenges —
4000000000000341 Attaches, then fails on charge card declined
Use test keys ( pk test … / sk test … ) in the app under test and assert that in setup.
Do not use 4111111111111111 — that is a generic Braintree/PayPal era Luhn number,
not a Stripe test card, and it does not deterministically decline.
The card field is in a cross origin Stripe iframe, so fill it through frameLocator , never
page.locator directly. Assert outcomes on UI copy for a smoke test, or more robustly on
the server side last payment error.decline code from paymentIntents.retrieve . Full
Playwright tests for success / card declined / insufficient funds :
references/stripe test cards.md .
3DS / SCA: The Nested Iframe Challenge
This is the hardest part to get right. The Stripe 3DS challenge is a frame nested inside
the Stripe modal frame — a single frameLocator cannot reach it. Chain
frameLocator outer → inner, then click Complete authentication .
What fails, and why:
page.locator(' card') → the input is cross origin; the locator matches nothing.
page.frames()[1] → frame index shifts when Stripe adds/reorders frames. Never select
frames by index.
await page.waitForTimeout(5000) → guessing the challenge duration. Wait on the element.
The correct shape (full test, including the fail authentication variant, in
references/playwright 3ds.md ):
4000002760003184 is the alternative SCA card for setup intent / first use flows; the eval
and docs accept it where a one time payment 3DS card is wanted.
Test Clocks: Server Side Time Travel
To test an annual renewal without waiting a year, use a Stripe test clock — a
server side construct. Client side fakes ( jest.useFakeTimers , sinon, mocking Date ) do
nothing to Stripe's billing engine.
Rules that bite if missed:
Create the clock at a frozen time , then attach the customer at creation with
test clock: clock.id . You cannot attach an existing customer to a clock afterward.
testHelpers.testClocks.advance moves time forward only — you cannot rewind. Advance
at most two billing cycles per call.
After advancing, poll the clock to ready , then assert the renewal invoice and webhooks.
Full create/advance/assert flow: references/webhooks and clocks.md (section 4).
Webhooks: Local Delivery, Signatures, Idempotency
Local delivery. Do not expose your endpoint with ngrok and do not poll the API for
status. stripe listen tunnels test events to localhost natively; stripe trigger fires
them on demand:
Copy that whsec … into STRIPE WEBHOOK SECRET . It is the signing secret , a different
value from STRIPE SECRET KEY ( sk test … ) — do not conflate them.
Signature verification. Mount express.raw on the webhook route before any global
express.json() , so constructEvent gets the raw body. A forged or tampered event must be
rejected with 400 ; never hand roll a === signature string comparison.
Idempotency. Stripe retries delivery, so the same event.id arrives twice. Request side
idempotency keys (for outbound API calls) do not dedup inbound webhooks. Store event.id
with a UNIQUE constraint and short circuit on conflict; an in memory set is lost on
restart and useless across instances. The handler returns 200 for a duplicate so Stripe
stops retrying, and fulfillment runs exactly once .
The signature test (valid accepted, forged → 400) and the "deliver the same event twice,
assert fulfilled once" idempotency test are in references/webhooks and clocks.md
(sections 2–3).
Failed Payments: Dunning and Refunds
To test a failed recurring charge end to end, subscribe with 4000000000000341 (SDK token
pm card chargeCustomerFail ) — it attaches to the customer but fails on the later
charge , which is what the renewal needs. Cards that decline at attach time can't be saved,
so they can't model a renewal failure.
Drive the lifecycle with a test clock:
1. Subscribe the customer (on a test clock) with the attach then fail card.
2. advance the clock past the renewal date → Stripe attempts the charge.
3. The charge fails → Stripe emits invoice.payment failed and the subscription goes
past due . Assert both.
4. Resolve by issuing a refund with refunds.create (fires charge.refunded ) — do
not "fix" it by deleting the subscription.
Full driver in references/webhooks and clocks.md (section 5).
Reconciliation: Fulfill on the Webhook, Not the Redirect
Mark an order paid only after a signature verified payment intent.succeeded webhook,
re confirmed against the API — never on the return url redirect or a client side success
flag, and never by polling with a sleep.
The reconciliation test asserts the order is still pending after the redirect and only
paid after the verified webhook: references/webhooks and clocks.md (section 6).
Multi PSP: Adyen, PayPal, Braintree
Stripe test cards do not work on other PSPs. Each has its own sandbox cards and sandbox
buyer accounts. Port the structure of your Stripe tests; swap in the PSP's sandbox values.
Never reuse Stripe PANs or live/production keys.
Adyen — own test cards (e.g. 4212345678910014 for 3DS2); many declines are driven by
the transaction amount ( .13 refused, .51 referral), not the card. Events arrive as
HMAC signed notifications.
PayPal — log in with a sandbox buyer account (sandbox personal email/password), not
a card. Confirm server side via the Orders API / webhooks, not the client onApprove .
Braintree — own sandbox test card numbers via Drop in UI / Hosted Fields; amount
drives transaction outcome, card number drives verification.
What stays the same: separate test/sandbox credentials, no real card, and fulfillment on the
verified server side event/notification. Details: references/multi psp.md .
Anti Patterns
1. Reaching for 4111111111111111
That Luhn valid number is a Braintree/PayPal era generic PAN, not a Stripe test card. Use
4242424242424242 for success and the specific decline cards ( 4000000000000002 ,
4000000000009995 ).
2. Treating the card field as a normal input
page.locator(' card number') silently matches nothing because the field is in a
cross origin iframe. Use frameLocator .
3. Selecting iframes by index
page.frames()[1] breaks the instant Stripe reorders frames. Match the frame by a stable
name prefix ( iframe[name^=" privateStripeFrame"] ) and chain frameLocator for the nested
3DS challenge.
4. waitForTimeout to "wait for the challenge"
Flaky on slow CI, wasteful on fast CI. Wait on the element ( expect(...).toBeVisible() /
auto waiting locator actions), never the clock.
5. Client side time mocking for billing
jest.useFakeTimers / sinon / mocking Date cannot move Stripe's server side billing.
Use a test clock.
6. ngrok or polling for local webhooks
stripe listen forward to localhost:3000/webhooks tunnels events natively; stripe
trigger fires them. No public tunnel, no status polling.
7. Parsing the body before verifying the signature
A global express.json() ahead of the webhook route destroys the raw body
constructEvent needs, so verification can never pass. Mount express.raw on the webhook
route first.
8. Confusing the request idempotency key with webhook dedup, or using an in memory set
Outbound idempotency keys don't dedup inbound webhooks; an in memory Set dies on restart.
Persist event.id with a UNIQUE constraint.
9. Fulfilling on the redirect / client success flag
The return url can be premature, replayed, or forged. Fulfill only on the verified
payment intent.succeeded webhook.
10. "Fixing" a failed renewal by deleting the subscription
The correct resolution is a refund via refunds.create , leaving the dunning lifecycle