Payments Testing Guide

How to Test PayPal Checkout End to End: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing PayPal Checkout with Playwright. Popup window handling, sandbox account setup, balance payments, guest card checkout, Pay Later flows, cancellation, and the cross-origin pitfalls that break real suites.

400M+

PayPal serves over 400 million active accounts globally. Its Checkout popup flow is one of the most common payment integrations on the web, and one of the hardest to test end to end because it relies on a cross-origin popup window.

0 popupCross-origin window
0Test scenarios
0Domains involved
< 0sPer test runtime

1. Why Testing PayPal Checkout Is Different

PayPal Checkout does not behave like Stripe, Braintree, or any card-input integration you have tested before. When your user clicks the PayPal button on your site, a full popup window opens on paypal.com. That popup is an entirely separate browser context on a cross-origin domain, with its own login flow, its own DOM, and its own session. Your main page sits idle in the background until the user approves the payment inside that popup, at which point the popup closes and your page receives an onApprove callback.

This architecture creates four structural testing challenges. First, the PayPal button itself renders inside an iframe on your page, so you need a frameLocator just to click it. Second, the popup is a new window that Playwright must capture via page.waitForEvent('popup') before you can interact with it. Third, the popup domain is paypal.com, which means all your locators inside that window target a page you do not control and that PayPal can redesign at any time. Fourth, the popup must close cleanly for the onApprove callback to fire on your original page. If the popup is blocked, dismissed, or stuck in an error state, your payment flow silently fails.

A complete PayPal Checkout test suite must handle all four surfaces: the iframe button click, the popup lifecycle, the cross-origin login and approval flow, and the callback-driven state transition on your own page. The sections below walk through each scenario with runnable Playwright TypeScript code.

PayPal Checkout Popup Flow

🌐

Your Page

User sees PayPal button in iframe

📦

Click Button

frameLocator targets PayPal iframe

↪️

Popup Opens

paypal.com in new window

🔒

Sandbox Login

Email + password on PayPal

💳

Approve Payment

User clicks Continue

Popup Closes

Window dismissed automatically

⚙️

onApprove Fires

Callback on your page

PayPal Checkout Sequence (Full Popup Lifecycle)

BrowserYour PagePayPal IframePopup (paypal.com)Your ServerNavigate to checkoutRender PayPal buttonClick PayPal buttonOpen popup windowEnter email + passwordClick Approve / ContinueonApprove callback firesPOST /api/paypal/capture{ status: COMPLETED }Show confirmation UI

2. Setting Up Your Test Environment

PayPal provides a full sandbox environment that mirrors production. Before writing a single test, you need three things: a sandbox business account (the merchant), a sandbox personal account (the buyer), and REST API credentials linked to the business account. All three are created in the PayPal Developer Dashboard.

Creating Sandbox Accounts

Navigate to developer.paypal.com, sign in, and go to Testing Tools, then Sandbox Accounts. Create one Business account and one Personal account. The personal account is the buyer your tests will log in as inside the popup. Note the email and password for both accounts. PayPal auto-generates passwords, so copy them immediately.

Sandbox Account Setup Checklist

  • Sign in to developer.paypal.com with your real PayPal account
  • Go to Testing Tools, then Sandbox Accounts
  • Create one Business (merchant) sandbox account
  • Create one Personal (buyer) sandbox account
  • Copy the auto-generated email and password for both accounts
  • Ensure the personal account has a positive sandbox balance
  • Store credentials in .env.test (never commit this file)
.env.test

REST API Credentials

Under Apps & Credentials in the Developer Dashboard, create a new app linked to your sandbox business account. This gives you a Client ID and Secret. Your server uses these to create orders and capture payments via the PayPal REST API. Your frontend uses the Client ID to render the PayPal buttons.

.env.test

Playwright Configuration for Popups

PayPal Checkout opens a popup window. By default, some Playwright configurations block popups or fail to track them. You need to ensure your browser context allows popups and that you register the popup event listener before clicking the PayPal button.

playwright.config.ts

Helper: Clicking the PayPal Button Inside Its Iframe

The PayPal JavaScript SDK renders its buttons inside an iframe on your page. Every test starts by clicking that button, so extract this into a shared helper that your scenarios can reuse.

// helpers/paypal.ts
import { Page } from '@playwright/test';

export async function clickPayPalButton(page: Page) {
  // The PayPal button iframe uses a name that starts with
  // "__zoid__paypal_buttons__" or a title containing "PayPal"
  const paypalFrame = page.frameLocator(
    'iframe[name^="__zoid__paypal_buttons"]'
  );

  // Inside the iframe, click the primary PayPal button
  await paypalFrame
    .getByRole('button', { name: /paypal/i })
    .first()
    .click();
}

Test Environment Setup Checklist

📧

Sandbox Accounts

Business + Personal in Developer Dashboard

🔒

REST API Creds

Client ID + Secret for sandbox app

⚙️

Env Variables

.env.test with all credentials

🌐

Playwright Config

Popup support, generous timeouts

Shared Helpers

clickPayPalButton utility

3. Scenario: Happy Path PayPal Balance Payment

1

PayPal Balance Payment

Moderate

The most common PayPal flow: the buyer logs into the PayPal popup with their sandbox credentials, pays with their PayPal balance, and the popup closes. Your page receives the onApprove callback, captures the order server-side, and shows a confirmation.

Goal

Starting from your checkout page, complete a full PayPal payment using sandbox buyer credentials, return to your page after popup closes, and confirm the order is captured.

Preconditions

  • App running at APP_BASE_URL with PayPal sandbox Client ID
  • Sandbox personal account has a positive balance
  • Server-side order creation endpoint is functional

Playwright vs Assrt Implementation

Happy Path: PayPal Balance Payment

import { test, expect } from '@playwright/test';
import { clickPayPalButton } from '../helpers/paypal';

test('happy path: PayPal balance payment succeeds', async ({ page }) => {
  // 1. Navigate to your checkout page
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Buy Pro plan' }).click();

  // 2. Wait for PayPal buttons to load inside their iframe
  const paypalFrame = page.frameLocator(
    'iframe[name^="__zoid__paypal_buttons"]'
  );
  await expect(
    paypalFrame.getByRole('button', { name: /paypal/i }).first()
  ).toBeVisible({ timeout: 15_000 });

  // 3. Register the popup listener BEFORE clicking the button.
  const popupPromise = page.waitForEvent('popup');
  await clickPayPalButton(page);
  const popup = await popupPromise;

  // 4. Wait for the PayPal login page to load in the popup
  await popup.waitForLoadState('domcontentloaded');

  // 5. Log in with sandbox buyer credentials
  await popup.getByLabel('Email or mobile number').fill(
    process.env.PAYPAL_SANDBOX_BUYER_EMAIL!
  );
  await popup.getByRole('button', { name: 'Next' }).click();
  await popup.getByLabel('Password').fill(
    process.env.PAYPAL_SANDBOX_BUYER_PASSWORD!
  );
  await popup.getByRole('button', { name: 'Log In' }).click();

  // 6. Wait for the review page and approve the payment
  await popup.waitForLoadState('domcontentloaded');
  await popup.getByRole('button', { name: /continue/i })
    .click({ timeout: 30_000 });

  // 7. The popup closes automatically after approval.
  await popup.waitForEvent('close', { timeout: 30_000 });

  // 8. Your onApprove callback fires. Assert success UI.
  await expect(page.getByRole('heading', { name: /thank you/i }))
    .toBeVisible({ timeout: 30_000 });
  await expect(page.getByText(/order confirmed/i)).toBeVisible();
});
60% fewer lines

4. Scenario: Guest Checkout with Card Inside PayPal

2

Guest Card Checkout via PayPal Popup

Complex

PayPal offers a guest checkout option where buyers can pay with a credit or debit card without logging into a PayPal account. This flow still happens inside the PayPal popup, but instead of entering PayPal credentials, the buyer fills in card details directly. This is a critical path because many buyers prefer not to create a PayPal account.

The Guest Checkout Flow

Inside the PayPal popup, there is typically a link or tab labeled something like "Pay with Debit or Credit Card" below the login form. Clicking it transitions the popup to a card entry form. This form is rendered by PayPal, not by your application, so the field labels and layout are controlled by PayPal and may vary by region. In sandbox mode, you can use any valid-format card number and it will succeed.

Playwright Implementation

test('guest checkout: card payment without PayPal account', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Buy Pro plan' }).click();

  const paypalFrame = page.frameLocator(
    'iframe[name^="__zoid__paypal_buttons"]'
  );
  await expect(
    paypalFrame.getByRole('button', { name: /paypal/i }).first()
  ).toBeVisible({ timeout: 15_000 });

  const popupPromise = page.waitForEvent('popup');
  await clickPayPalButton(page);
  const popup = await popupPromise;

  await popup.waitForLoadState('domcontentloaded');

  // Click the guest checkout link (below the login form)
  await popup.getByRole('link', { name: /debit or credit card/i })
    .click({ timeout: 15_000 });

  // Fill in card details inside the PayPal popup
  await popup.getByLabel(/card number/i).fill('4032039317984658');
  await popup.getByLabel(/expiry/i).fill('12/2030');
  await popup.getByLabel(/CVV|CVC|security code/i).fill('123');

  // Fill billing info
  await popup.getByLabel(/first name/i).fill('Test');
  await popup.getByLabel(/last name/i).fill('Buyer');
  await popup.getByLabel(/email/i).fill('guest@assrt.ai');
  await popup.getByLabel(/phone/i).fill('4155551234');

  // Address fields
  await popup.getByLabel(/address line 1/i).fill('123 Test Street');
  await popup.getByLabel(/city/i).fill('San Jose');
  await popup.getByLabel(/state/i).selectOption('CA');
  await popup.getByLabel(/postal code|zip/i).fill('95131');

  // Submit the guest payment
  await popup.getByRole('button', { name: /pay now|continue/i })
    .click({ timeout: 15_000 });

  // Wait for popup to close and assert on the original page
  await popup.waitForEvent('close', { timeout: 30_000 });

  await expect(page.getByRole('heading', { name: /thank you/i }))
    .toBeVisible({ timeout: 30_000 });
});

Assrt Plain-English Equivalent

# scenarios/paypal-guest-card.assrt
describe: Guest checkout with card inside PayPal popup

given:
  - I am on the checkout page

steps:
  - click "Buy Pro plan"
  - click the PayPal button inside its iframe
  - a popup window opens on paypal.com
  - in the popup, click "Pay with Debit or Credit Card"
  - fill the card number with 4032039317984658
  - fill the expiry with 12/2030
  - fill the CVV with 123
  - fill billing name, email, phone, and address
  - click "Pay Now"
  - the popup closes

expect:
  - the page shows a thank you heading
  - the order API returns "captured" status within 20 seconds

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started

5. Scenario: User Cancels the PayPal Popup

3

PayPal Popup Cancellation

Moderate

Not every user completes the PayPal flow. Some close the popup window before approving. Others click a cancel link inside the PayPal UI. Your application must handle both gracefully: the onCancel callback should fire, the order should remain unpaid, and the user should see a clear message explaining they can try again.

There are two cancellation paths to test. The first is the user closing the popup window directly (clicking the X button or the browser dismissing it). The second is the user clicking a "Cancel and return" link inside the PayPal approval page. Both should trigger the same recovery behavior on your page.

Playwright Implementation: Close the Popup Window

test('cancel: user closes the PayPal popup window', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Buy Pro plan' }).click();

  const paypalFrame = page.frameLocator(
    'iframe[name^="__zoid__paypal_buttons"]'
  );
  await expect(
    paypalFrame.getByRole('button', { name: /paypal/i }).first()
  ).toBeVisible({ timeout: 15_000 });

  const popupPromise = page.waitForEvent('popup');
  await clickPayPalButton(page);
  const popup = await popupPromise;

  await popup.waitForLoadState('domcontentloaded');

  // Close the popup without completing the flow
  await popup.close();

  // Assert your page handles the cancellation
  await expect(page.getByText(/payment cancelled|try again/i))
    .toBeVisible({ timeout: 15_000 });

  // The PayPal button should still be available for retry
  await expect(
    paypalFrame.getByRole('button', { name: /paypal/i }).first()
  ).toBeVisible();

  // Assert no order was created
  const response = await page.request.get('/api/orders/latest');
  const body = await response.json();
  expect(body.status).not.toBe('captured');
});

Playwright Implementation: Cancel Link Inside Popup

test('cancel: user clicks cancel link inside PayPal', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Buy Pro plan' }).click();

  const paypalFrame = page.frameLocator(
    'iframe[name^="__zoid__paypal_buttons"]'
  );
  await expect(
    paypalFrame.getByRole('button', { name: /paypal/i }).first()
  ).toBeVisible({ timeout: 15_000 });

  const popupPromise = page.waitForEvent('popup');
  await clickPayPalButton(page);
  const popup = await popupPromise;

  await popup.waitForLoadState('domcontentloaded');

  // Log in first, then cancel on the approval page
  await popup.getByLabel('Email or mobile number').fill(
    process.env.PAYPAL_SANDBOX_BUYER_EMAIL!
  );
  await popup.getByRole('button', { name: 'Next' }).click();
  await popup.getByLabel('Password').fill(
    process.env.PAYPAL_SANDBOX_BUYER_PASSWORD!
  );
  await popup.getByRole('button', { name: 'Log In' }).click();
  await popup.waitForLoadState('domcontentloaded');

  // Click the cancel link on the review page
  await popup.getByRole('link', { name: /cancel/i }).click();

  // Popup closes after cancel
  await popup.waitForEvent('close', { timeout: 15_000 });

  // Assert cancellation is handled on your page
  await expect(page.getByText(/payment cancelled|try again/i))
    .toBeVisible({ timeout: 15_000 });
});

Assrt Plain-English Equivalent

# scenarios/paypal-cancel.assrt
describe: User cancels the PayPal popup

given:
  - I am on the checkout page

steps:
  - click "Buy Pro plan"
  - click the PayPal button inside its iframe
  - a popup window opens on paypal.com
  - close the popup window without completing payment

expect:
  - the page shows a cancellation or retry message
  - the PayPal button is still available
  - no order was captured

6. Scenario: Pay Later / Pay in 4 Installments

4

Pay Later / Pay in 4

Complex

PayPal offers "Pay Later" and "Pay in 4" options that let buyers split their purchase into installments. When enabled, a separate "Pay Later" button renders alongside the standard PayPal button inside the iframe. Clicking it opens the same popup window but routes the buyer through an installment approval flow instead of a single payment.

In sandbox mode, Pay Later is available for US accounts with orders between $30 and $2,000. The sandbox will present the installment plan details and let the test buyer approve. Your server still captures the full order amount; PayPal handles the installment collection from the buyer independently.

Playwright Implementation

test('Pay Later: buyer approves Pay in 4 installments', async ({ page }) => {
  // Order must be $30-$2000 for Pay Later eligibility
  await page.goto('/checkout?plan=pro-annual');
  await page.getByRole('button', { name: 'Buy Pro plan' }).click();

  const paypalFrame = page.frameLocator(
    'iframe[name^="__zoid__paypal_buttons"]'
  );

  // The Pay Later button is a separate button inside the iframe
  const payLaterButton = paypalFrame
    .getByRole('button', { name: /pay later/i })
    .first();
  await expect(payLaterButton).toBeVisible({ timeout: 15_000 });

  const popupPromise = page.waitForEvent('popup');
  await payLaterButton.click();
  const popup = await popupPromise;

  await popup.waitForLoadState('domcontentloaded');

  // Log in with sandbox buyer
  await popup.getByLabel('Email or mobile number').fill(
    process.env.PAYPAL_SANDBOX_BUYER_EMAIL!
  );
  await popup.getByRole('button', { name: 'Next' }).click();
  await popup.getByLabel('Password').fill(
    process.env.PAYPAL_SANDBOX_BUYER_PASSWORD!
  );
  await popup.getByRole('button', { name: 'Log In' }).click();

  await popup.waitForLoadState('domcontentloaded');

  // The Pay Later flow shows installment details.
  // Verify the installment info is displayed.
  await expect(popup.getByText(/pay in 4|4 payments/i))
    .toBeVisible({ timeout: 20_000 });

  // Approve the installment plan
  await popup.getByRole('button', { name: /agree.*continue|continue/i })
    .click({ timeout: 15_000 });

  await popup.waitForEvent('close', { timeout: 30_000 });

  // Assert success on your page (same as regular PayPal flow)
  await expect(page.getByRole('heading', { name: /thank you/i }))
    .toBeVisible({ timeout: 30_000 });
});

Assrt Plain-English Equivalent

# scenarios/paypal-pay-later.assrt
describe: Pay Later installment flow via PayPal

given:
  - I am on the checkout page with a $99 annual plan
  - PayPal sandbox buyer credentials are configured

steps:
  - click "Buy Pro plan"
  - click the "Pay Later" button inside the PayPal iframe
  - a popup window opens on paypal.com
  - in the popup, log in with sandbox buyer credentials
  - verify installment details show "Pay in 4" or "4 payments"
  - click "Agree and Continue" to approve the installment plan
  - the popup closes

expect:
  - the page shows a thank you heading
  - the order API returns "captured" status within 20 seconds

7. Scenario: Server-Side Order Capture and Error Handling

With PayPal Checkout, the buyer approves the payment in the popup, but the actual money transfer does not happen until your server calls the PayPal Orders API to capture the order. This is fundamentally different from Stripe Checkout, where Stripe handles the capture automatically. If your server-side capture fails, the buyer approved a payment that never went through.

The onApprove callback on your frontend receives the order ID. Your frontend sends that ID to your server, which calls POST /v2/checkout/orders/{id}/capture on the PayPal API. If that call fails (network error, insufficient buyer funds at capture time, order already captured), your application must show a meaningful error and not leave the buyer thinking they paid successfully.

Server-Side Capture Flow

Buyer Approves

onApprove fires with order ID

🌐

Frontend POST

Sends order ID to your server

⚙️

Server Captures

POST /v2/checkout/orders/{id}/capture

💳

PayPal Responds

COMPLETED or error status

UI Updates

Success or error message

Testing the Capture Failure Path

To test capture failures, intercept the network request from your frontend to your server and simulate a failure response. This verifies that your onApprove handler correctly shows an error state when the capture call does not succeed.

test('capture failure: server returns error after approval', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Buy Pro plan' }).click();

  // Intercept your server's capture endpoint to simulate failure
  await page.route('**/api/paypal/capture', (route) => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({
        error: 'INSTRUMENT_DECLINED',
        message: 'The instrument presented was declined.',
      }),
    });
  });

  const paypalFrame = page.frameLocator(
    'iframe[name^="__zoid__paypal_buttons"]'
  );
  await expect(
    paypalFrame.getByRole('button', { name: /paypal/i }).first()
  ).toBeVisible({ timeout: 15_000 });

  const popupPromise = page.waitForEvent('popup');
  await clickPayPalButton(page);
  const popup = await popupPromise;

  await popup.waitForLoadState('domcontentloaded');

  // Complete the PayPal login and approval
  await popup.getByLabel('Email or mobile number').fill(
    process.env.PAYPAL_SANDBOX_BUYER_EMAIL!
  );
  await popup.getByRole('button', { name: 'Next' }).click();
  await popup.getByLabel('Password').fill(
    process.env.PAYPAL_SANDBOX_BUYER_PASSWORD!
  );
  await popup.getByRole('button', { name: 'Log In' }).click();
  await popup.waitForLoadState('domcontentloaded');
  await popup.getByRole('button', { name: /continue/i })
    .click({ timeout: 30_000 });

  await popup.waitForEvent('close', { timeout: 30_000 });

  // The capture fails server-side. Assert error UI.
  await expect(page.getByText(/payment could not be completed/i))
    .toBeVisible({ timeout: 15_000 });
  await expect(page.getByRole('button', { name: /try again|retry/i }))
    .toBeVisible();
});

Testing Idempotent Capture

Another important edge case: the buyer approves, your frontend sends the capture request, the network drops, and the frontend retries. If your server does not handle idempotent capture correctly, you may try to capture the same order twice and get an ORDER_ALREADY_CAPTURED error. Test this by letting the first capture succeed, then verifying that a second capture attempt does not crash your application or show a confusing error to the buyer.

test('idempotent capture: retrying does not break the flow', async ({ page, request }) => {
  // Complete a normal PayPal flow and capture successfully
  // ... (PayPal popup login and approval as above)

  // After the first successful capture, simulate a retry
  // by calling the capture endpoint again with the same order ID
  const orderId = await page.evaluate(
    () => (window as any).__lastPayPalOrderId
  );

  const retryResponse = await request.post('/api/paypal/capture', {
    data: { orderId },
  });

  const retryBody = await retryResponse.json();
  // Your server should handle this gracefully
  expect([200, 409]).toContain(retryResponse.status());
  expect(retryBody.error).not.toBe('UNHANDLED_EXCEPTION');
});

8. Common Pitfalls That Break PayPal Test Suites

Popup Blockers and Missed Popup Events

The single most common failure in PayPal Checkout testing is missing the popup. If you call clickPayPalButton(page) and then call page.waitForEvent('popup'), the popup may have already opened and closed before the listener was registered. Always create the popup promise first, then click. The correct order is: const popupPromise = page.waitForEvent('popup'), then click, then await popupPromise. Getting this order wrong causes tests to hang indefinitely.

Sandbox Account Expiry and Password Resets

PayPal sandbox accounts can expire or have their passwords reset by PayPal without warning. If your tests suddenly start failing at the login step, check the Developer Dashboard to confirm the sandbox personal account still exists and its password has not changed. Consider creating fresh sandbox accounts at the start of each CI run using the PayPal Sandbox Accounts API.

// Create a fresh sandbox buyer before each test run
const token = await getPayPalAccessToken();
const res = await fetch(
  'https://api-m.sandbox.paypal.com/v1/customer/partners/merchant-accounts',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      // Use PayPal's Sandbox Accounts API to generate
      // a fresh personal account for each test run
    }),
  }
);

PayPal Button Iframe Selectors Are Unstable

The PayPal JavaScript SDK generates iframe names dynamically. The name attribute includes a hash or session identifier that changes on every page load. Never match on the full iframe name. Use a prefix match like iframe[name^="__zoid__paypal_buttons"] and avoid matching on any suffix characters. Similarly, the button inside the iframe may be labeled differently depending on the buyer's locale, so use a case-insensitive regex like /paypal/i rather than an exact string match.

Sandbox Environment Slowness

The PayPal sandbox is notoriously slower than production. Pages inside the popup can take 10 to 20 seconds to load, especially the login page and the review page. If your tests use the default Playwright timeout of 5 seconds, they will fail intermittently. Set a generous timeout on every interaction inside the popup. A timeout of 30 seconds is reasonable for sandbox testing. This is not a waitForTimeout sleep; it is a maximum wait on an auto-retrying assertion or action.

PayPal Login Flow Changes

PayPal periodically updates its login UI inside the popup. The email and password fields may be on separate pages (two-step login) or on the same page (single-step login). Your tests must handle both. Check whether a "Next" button exists after filling the email; if it does, click it and then fill the password on the next page. If it does not, both fields are on the same page and you can fill them in sequence.

Not Cleaning Up Sandbox Orders

Every test run creates real orders in your PayPal sandbox. Unlike Stripe, PayPal does not offer a convenient test mode cleanup. Over time, the sandbox accumulates thousands of orders that make debugging painful. Add a teardown step that voids uncaptured orders and records captured order IDs for manual review if needed.

9. Writing These Scenarios in Plain English with Assrt

Every scenario above involves managing popups, cross-origin navigation, iframe frame locators, and timing-sensitive assertions. A single PayPal test is 50 to 90 lines of Playwright TypeScript. Multiply that by the five or six scenarios you need for reasonable coverage, and you have a 400-line file that breaks the moment PayPal renames a button label or restructures its login flow.

Assrt lets you describe each scenario in plain English. It generates the Playwright code, handles the popup lifecycle, and regenerates selectors automatically when PayPal changes its UI. The happy path from Section 3 looks like this:

# scenarios/paypal-full-suite.assrt
describe: Complete PayPal Checkout test suite

---
scenario: Happy path PayPal balance payment
given:
  - I am on the checkout page
steps:
  - click "Buy Pro plan"
  - click the PayPal button
  - in the PayPal popup, log in with sandbox credentials
  - approve the payment
expect:
  - the page shows "order confirmed"
  - the order is captured in the database

---
scenario: Guest card checkout
given:
  - I am on the checkout page
steps:
  - click "Buy Pro plan"
  - click the PayPal button
  - in the PayPal popup, choose "Pay with Debit or Credit Card"
  - fill in card 4032039317984658, expiry 12/2030, CVV 123
  - fill in billing details and submit
expect:
  - the page shows "order confirmed"

---
scenario: User cancels PayPal popup
given:
  - I am on the checkout page
steps:
  - click "Buy Pro plan"
  - click the PayPal button
  - close the PayPal popup without completing
expect:
  - the page shows a cancellation message
  - the PayPal button is still available for retry

---
scenario: Pay Later installments
given:
  - I am on the checkout page with a $99 plan
steps:
  - click "Buy Pro plan"
  - click the "Pay Later" button
  - in the popup, log in and approve the installment plan
expect:
  - the page shows "order confirmed"
  - the order is captured at the full amount
PayPal Checkout Test Suite Run

Assrt compiles each scenario block into the same Playwright TypeScript you saw in the sections above, committed to your repo as a real test file you can inspect, run, and debug. When PayPal changes its popup login from two-step to single-step, or renames the "Continue" button to "Agree and Pay," Assrt detects the failure, analyzes the new popup DOM, and opens a pull request with the updated locators. Your scenario file stays unchanged.

Start with the happy path scenario. Once it is green in your CI, add guest checkout, then cancellation, then Pay Later, then the capture failure test. In a single afternoon you can build the full PayPal Checkout coverage that most teams never manage to ship by hand.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk