Skip to main content

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

LayerPitfallE2E fix
RedirectTest ends on klarna.comAssert return URL + probe
Approval UISandbox always approvesAlso test denial fixtures
SettlementWebhook hours later in prodSandbox fast webhooks + poll probe
Partial captureOrder authorized not capturedProbe capture_status
Currency/regionBNPL unavailable for US cartNegative geo scenario
IdempotencyDouble webhook grantsProbe single fulfillment

BNPL makes the Stripe success-page race worse: customers return to your site before lender confirms funds.

Complexity map

ScenarioEdge caseWhy tests breakApproach
Klarna redirectPopup vs full redirectLost page contextwaitForURL on return domain
Affirm inlineiframe + modalFrame not foundScope frameLocator; see embeds guide
Afterpay AU vs USDifferent sandbox URLsWrong test envRegion-specific fixtures
Denied applicationSoft decline UIOnly happy path testedSandbox decline persona
Abandoned redirectUser closes tabOrder stuck pendingTimeout + probe pending
Webhook delayReturn before payment.capturedFlakeexpect.poll 30–60s sandbox
Partial refundLender webhook sequenceWrong order stateFixture event order matrix
Stripe + KlarnaPI status vs Checkout SessionConfused assertsProbe unified order row
Metadata runIdLost in redirectCannot traceSet at session create
Multi-item cartBNPL min/max amountUnexpected ineligibleBoundary seed amounts
3DS + BNPLRare comboUntested stackDocument combined card path
Connect / marketplaceSplit payoutsWrong merchant probePer-seller order scope
Test clockSubscription BNPLN/A for goodsSeparate billing guide
Mobile WebViewRedirect chain breaksSkip or API ArrangeProbe-only job
Currency mismatchEUR cart, USD BNPLSilent disableAssert BNPL option visible

Sandbox setup

ProviderTest mode entryAuthority
KlarnaPlayground / test credentialsKlarna payments docs
Stripe KlarnaTest mode + pm_card_visa flowStripe Klarna
AffirmSandbox API keysAffirm Sandbox
AfterpaySandbox merchantAfterpay 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:

  1. Create PaymentIntent with payment_method_types including klarna
  2. Complete redirect in test mode per Stripe testing
  3. Poll probe; optionally forward payment_intent.succeeded via Stripe CLI

Keeps one order model—probe still wins over Payment Element UI.

Anti-patterns

Anti-patternWhy it failsBetter approach
Assert "Thank you" on returnWebhook not processedPoll probe paid
Live BNPL in CICompliance + flakeSandbox only
Skip denial scenariosSupport load on declinesDecline persona spec
sleep(10000) after redirectStill racesexpect.poll
No runId in metadataParallel collisionPer-run checkout session
UI-only cart assertOrder created server-sideProbe order + cart

External references

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.

  1. Arrange: BNPL checkout session with metadata.runId; webhook secret configured; sandbox credentials in CI.
  2. Act: Complete provider sandbox approval flow; land on merchant return URL.
  3. 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.

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.

Start free on TestChimp · Book a demo