How to Test BNPL Checkout (Klarna, Affirm, Afterpay)
Short answer
BNPL checkout is redirect-heavy and asynchronous: your cart shows "approved" while settlement webhooks arrive minutes later—or never if the customer abandons the provider flow. Test Klarna, Affirm, and Afterpay with sandbox credentials, return URL handling, signed webhook fixtures, and probe Assert on order.status—not the interim "processing" banner alone.
Part of Testing Guides by business flow.
Who this is for
Ecommerce and SaaS teams offering Buy Now Pay Later via Klarna, Affirm, Afterpay/Clearpay, or Stripe Payment Element BNPL methods who need Playwright E2E that covers approval, denial, redirect return, and webhook fulfillment.
Typical integrations: Stripe + Klarna, direct Klarna Payments API, Affirm inline checkout, Afterpay redirect, Shopify BNPL apps, or custom checkout on Next.js.
Why BNPL needs different Arrange/Assert
| Layer | Pitfall | E2E fix |
|---|---|---|
| Redirect | Test ends on klarna.com | Assert return URL + probe |
| Approval UI | Sandbox always approves | Also test denial fixtures |
| Settlement | Webhook hours later in prod | Sandbox fast webhooks + poll probe |
| Partial capture | Order authorized not captured | Probe capture_status |
| Currency/region | BNPL unavailable for US cart | Negative geo scenario |
| Idempotency | Double webhook grants | Probe single fulfillment |
BNPL makes the Stripe success-page race worse: customers return to your site before lender confirms funds.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Klarna redirect | Popup vs full redirect | Lost page context | waitForURL on return domain |
| Affirm inline | iframe + modal | Frame not found | Scope frameLocator; see embeds guide |
| Afterpay AU vs US | Different sandbox URLs | Wrong test env | Region-specific fixtures |
| Denied application | Soft decline UI | Only happy path tested | Sandbox decline persona |
| Abandoned redirect | User closes tab | Order stuck pending | Timeout + probe pending |
| Webhook delay | Return before payment.captured | Flake | expect.poll 30–60s sandbox |
| Partial refund | Lender webhook sequence | Wrong order state | Fixture event order matrix |
| Stripe + Klarna | PI status vs Checkout Session | Confused asserts | Probe unified order row |
Metadata runId | Lost in redirect | Cannot trace | Set at session create |
| Multi-item cart | BNPL min/max amount | Unexpected ineligible | Boundary seed amounts |
| 3DS + BNPL | Rare combo | Untested stack | Document combined card path |
| Connect / marketplace | Split payouts | Wrong merchant probe | Per-seller order scope |
| Test clock | Subscription BNPL | N/A for goods | Separate billing guide |
| Mobile WebView | Redirect chain breaks | Skip or API Arrange | Probe-only job |
| Currency mismatch | EUR cart, USD BNPL | Silent disable | Assert BNPL option visible |
Sandbox setup
| Provider | Test mode entry | Authority |
|---|---|---|
| Klarna | Playground / test credentials | Klarna payments docs |
| Stripe Klarna | Test mode + pm_card_visa flow | Stripe Klarna |
| Affirm | Sandbox API keys | Affirm Sandbox |
| Afterpay | Sandbox merchant | Afterpay Sandbox |
# CI secrets (example)
KLARNA_USERNAME_US=...
KLARNA_PASSWORD_US=...
AFFIRM_PUBLIC_KEY=sandbox_...
AFFIRM_PRIVATE_KEY=sandbox_...
AFTERPAY_MERCHANT_ID=sandbox_...
STRIPE_SECRET_KEY=sk_test_... # if using Stripe orchestration
E2E_TEST_MODE=true
E2E: redirect BNPL (Klarna / Afterpay pattern)
// @Scenario: checkout/bnpl-klarna-approved
import { test, expect } from '../fixtures/run';
test('Klarna approval settles order via webhook', async ({ page, request, runId }) => {
const { checkoutUrl, orderId } = await request.post('/api/test/create-bnpl-checkout', {
data: { runId, provider: 'klarna', amountCents: 15000 },
}).then(r => r.json());
await page.goto(checkoutUrl);
await page.getByRole('button', { name: /pay with klarna/i }).click();
// Provider sandbox — use documented test persona (approval)
await page.waitForURL(/your-shop\.test\/checkout\/return/, { timeout: 60_000 });
await expect.poll(async () => {
const res = await request.get(`/api/test/probe-order?orderId=${orderId}`);
return (await res.json()).status;
}, { timeout: 45_000 }).toBe('paid');
});
Return URL assertion is necessary; probe paid is authoritative.
Webhook Assert (settlement)
Mirror Stripe webhook testing:
// Arrange: order in authorized state after redirect
const fixture = loadBnplWebhook('payment.captured', { orderId, runId });
await postSignedWebhook(fixture); // HMAC per provider docs
await expect.poll(() => probeOrderStatus(orderId)).toBe('paid');
await expect.poll(() => probeFulfillmentCount(orderId)).toBe(1);
Test duplicate webhook delivery—BNPL providers retry like card networks.
Denial and ineligible paths
test('BNPL decline leaves cart intact', async ({ page, request, runId }) => {
const { checkoutUrl, orderId } = await seedBnplCheckout(runId, { persona: 'decline' });
await page.goto(checkoutUrl);
await completeKlarnaSandbox(page, { outcome: 'denied' });
await expect(page.getByText(/could not approve/i)).toBeVisible();
await expect.poll(() => probeOrderStatus(orderId)).toBe('pending');
await expect.poll(() => probeCartItemCount(runId)).toBeGreaterThan(0);
});
Stripe-orchestrated BNPL
When BNPL runs through Stripe Payment Element:
- Create PaymentIntent with
payment_method_typesincludingklarna - Complete redirect in test mode per Stripe testing
- Poll probe; optionally forward
payment_intent.succeededvia Stripe CLI
Keeps one order model—probe still wins over Payment Element UI.
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Assert "Thank you" on return | Webhook not processed | Poll probe paid |
| Live BNPL in CI | Compliance + flake | Sandbox only |
| Skip denial scenarios | Support load on declines | Decline persona spec |
sleep(10000) after redirect | Still races | expect.poll |
No runId in metadata | Parallel collision | Per-run checkout session |
| UI-only cart assert | Order created server-side | Probe order + cart |
External references
- Klarna payments integration
- Stripe Klarna payments
- Affirm Direct Checkout
- Afterpay online integration
- Playwright
waitForURL - Generic webhook idempotency
Example scenario
Situation: Customer selects Klarna, completes sandbox approval, returns to merchant site before capture webhook fires.
Expected outcome: Order status paid; inventory reserved once; no duplicate shipment on webhook retry.
Why UI-only automation breaks: Return page shows processing spinner forever while probe stays pending—test passes if it does not poll.
- Arrange: BNPL checkout session with metadata.runId; webhook secret configured; sandbox credentials in CI.
- Act: Complete provider sandbox approval flow; land on merchant return URL.
- Assert: expect.poll probe-order paid; duplicate webhook does not double-fulfill.
TestChimp workflow: Instrument bnpl_provider × settlement_outcome in TrueCoverage; /testchimp evolve when Affirm declines spike untested.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Related
Frequently asked questions
Can I automate Klarna sandbox without real bank login?
Yes—Klarna playground provides test personas for approval and denial. Follow provider docs for market-specific flows; never use production lender credentials in CI.
BNPL returned to my site but order still pending—what should tests assert?
Poll probe until status is paid or a documented terminal state (declined, expired). Return URL alone is insufficient when settlement is webhook-driven.
Affirm inline vs redirect—which is harder in Playwright?
Inline uses iframes—scope frameLocator and wait for readiness. Redirect flows need waitForURL on return plus webhook poll. Test both if you support both.
How do I test BNPL webhooks without waiting minutes?
Use sandbox environments with accelerated settlement, or POST signed webhook fixtures to your handler and poll probe—same pattern as Stripe webhook guide.
Should BNPL tests run on every PR?
Keep one smoke redirect+probe path per provider on PR; run full denial/refund matrix nightly to avoid slow flaky redirects blocking merges.
Stripe Payment Element BNPL vs direct Klarna API?
Stripe simplifies PCI and unifies webhooks; direct APIs need provider-specific signing. Either way, probe your order row—not provider widget text.
How do I test BNPL unavailable for cart amount?
Seed cart below minimum or above maximum; assert BNPL option hidden or disabled and probe no BNPL session created.
TestChimp with BNPL checkout flows?
/testchimp init adds probe routes for order state; /testchimp test links redirect-heavy specs to markdown scenarios so iframe/URL changes get repaired with business context.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.