Billing & Subscription Testing Guide

How to Test Stripe Billing Portal with Playwright: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing Stripe's hosted Billing Portal with Playwright. Cancel subscriptions, upgrade and downgrade plans, verify proration previews, update payment methods, handle webhook-driven state changes, and manipulate test clocks for subscription cycle testing.

$1T+

Stripe processes over one trillion dollars in payment volume annually, and Billing Portal is the self-service interface millions of subscribers use to manage their plans.

Stripe 2024 Annual Letter

0 hosted domainCross-origin redirect
0Billing scenarios covered
0 webhooksState changes to verify
0%Fewer lines with Assrt

Stripe Billing Portal End-to-End Flow

BrowserYour AppStripe APIBilling PortalWebhooksClick Manage BillingPOST /billing_portal/sessionsReturn portal session URL302 redirect to billing.stripe.comUser interacts with portalApply subscription changeFire customer.subscription.updatedRedirect to return_urlPOST /webhooks (state sync)

1. Why Testing Stripe Billing Portal Is Harder Than It Looks

Stripe Billing Portal is a hosted page on billing.stripe.comthat your application redirects customers to. Unlike embedded Stripe Elements, where you control the parent page and reach into iframes, the Billing Portal takes over the entire browser window. Your Playwright test starts on your domain, navigates to Stripe's domain for the portal interaction, and then returns to your domain via a return_url. That cross-origin hop is the first hurdle.

The second hurdle is that the Billing Portal is not a simple form submission. Canceling a subscription, changing a plan, or updating a payment method each triggers a different Stripe API call behind the scenes. The portal page itself renders dynamically based on your portal configuration, the customer's current subscription state, and the available plans you have configured. Different customers see different options, making it impossible to write a single selector path that works for every case.

Third, the real state change happens asynchronously via webhooks. When a customer cancels their subscription in the portal, Stripe fires a customer.subscription.updated webhook to your server. Your application must process that webhook before the UI reflects the cancellation. If your test only checks the portal redirect, it misses the most critical part: whether your backend actually handled the state transition correctly.

Fourth, payment method updates inside the portal use a Stripe Elements iframe for PCI compliance. You are back to the iframe-within-a-hosted-page problem. Fifth, testing billing cycles (monthly renewals, trial expirations, past-due recovery) requires Stripe Test Clocks to advance time programmatically, because you cannot wait 30 real days in a CI pipeline.

Billing Portal Interaction Model

🌐

Your App

User clicks Manage Billing

⚙️

API Call

POST /billing_portal/sessions

↪️

Redirect

302 to billing.stripe.com

💳

Portal

Customer self-service

🔔

Webhook

subscription.updated

Your App

State synced, UI updated

A thorough Billing Portal test suite must cover all of these surfaces. The sections below walk through each scenario with runnable Playwright TypeScript you can paste directly into your test project.

2. Setting Up a Reliable Test Environment

Stripe provides a test mode that mirrors production behavior with no real charges. Every Stripe account has parallel test and live API key pairs. All Billing Portal testing uses test mode exclusively. Use the test mode secret key (prefixed sk_test_) for API calls and the test mode publishable key (prefixed pk_test_) for any client-side Elements rendering inside the portal.

Stripe Billing Portal Test Setup Checklist

  • Create a dedicated Stripe test mode project (or use your existing test mode keys)
  • Configure a Billing Portal with cancel, plan switching, and payment method update enabled
  • Create at least two Price objects (e.g., Basic $10/mo and Pro $25/mo) with lookup_keys
  • Set the portal return_url to your app's billing settings page
  • Install Stripe CLI for local webhook forwarding
  • Create a webhook endpoint in your app that handles subscription events
  • Set up Stripe Test Clocks for billing cycle simulation (optional, for Section 8)
  • Disable proration for initial testing, enable it when testing Section 5

Environment Variables

.env.test

Test Fixtures: Customer and Subscription Factory

Every test needs a Stripe customer with an active subscription. Rather than sharing customers across tests (which causes flaky state collisions), create a fresh customer and subscription per test using a Playwright fixture. Use Stripe's test card number 4242424242424242 with any future expiration date and any three-digit CVC.

test/fixtures/stripe-fixtures.ts

Stripe CLI for Local Webhook Forwarding

The Stripe CLI forwards webhook events from Stripe's test mode to your local development server. Without this, your backend never receives the subscription state changes triggered by portal interactions. Start the CLI listener before running your test suite.

Start Stripe CLI Webhook Forwarding

Playwright Configuration

playwright.config.ts

3. Scenario: Cancel a Subscription

Cancellation is the most critical billing portal flow to test. When a customer cancels, Stripe sets cancel_at_period_end: true on the subscription and fires a customer.subscription.updated webhook. Your application must process that webhook and reflect the pending cancellation in the UI. The subscription remains active until the current period ends, so the customer should still have access until then.

1

Cancel Subscription via Billing Portal

Moderate

Goal

Navigate to the Stripe Billing Portal, cancel an active subscription, confirm the portal shows the cancellation, verify the redirect back to your app, and assert that your backend received and processed the webhook.

Preconditions

  • Customer has an active subscription to the Basic plan
  • Portal configuration has cancellation enabled
  • Stripe CLI is forwarding webhooks to your local server

Playwright Implementation

cancel-subscription.spec.ts

What to Assert Beyond the UI

The portal UI confirmation alone is insufficient. You should also verify the Stripe subscription object directly via the API to confirm that cancel_at_period_end is now true.

cancel-verification.ts

4. Scenario: Upgrade to a Higher Plan

Plan upgrades in the Billing Portal show the customer their current plan alongside the available upgrade options. When the customer confirms the upgrade, Stripe creates a prorated invoice for the difference, applies the new plan immediately, and fires both customer.subscription.updated and invoice.paid webhooks. The portal preview screen shows the proration amount before confirmation, which you should verify in your test.

2

Upgrade Plan with Proration

Moderate

Goal

From the Billing Portal, switch from the Basic plan ($10/mo) to the Pro plan ($25/mo), verify the proration preview amount, confirm the upgrade, and validate that the subscription is now on the Pro price.

Playwright Implementation

upgrade-plan.spec.ts

Upgrade Plan: Playwright vs Assrt

test('upgrade from Basic to Pro plan', async ({
  page, portalUrl,
}) => {
  await page.goto(portalUrl);
  await page.waitForURL(/billing\.stripe\.com/);
  await page.getByRole('button', { name: /update plan/i }).click();
  await page.getByText(/pro/i).click();
  const preview = page.getByText(/you'll be charged/i);
  await expect(preview).toBeVisible({ timeout: 5_000 });
  const text = await preview.textContent();
  expect(text).toMatch(/\$\d+\.\d{2}/);
  await page.getByRole('button', { name: /confirm/i }).click();
  await expect(
    page.getByText(/your plan has been updated/i)
  ).toBeVisible({ timeout: 10_000 });
  await page.getByRole('link', { name: /return/i }).click();
  await page.waitForURL(/\/billing/);
  await expect(
    page.getByText(/pro plan/i)
  ).toBeVisible({ timeout: 15_000 });
});
47% fewer lines

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started

5. Scenario: Downgrade with Proration Preview

Downgrading is the inverse of upgrading, but with a subtle difference in proration behavior. When a customer moves from a more expensive plan to a cheaper one, Stripe calculates a credit for the unused portion of the current billing period. By default, this credit is applied to the next invoice rather than refunded immediately. The Billing Portal preview screen shows this credit amount, which is important for customer trust and should be verified in your tests.

3

Downgrade Plan and Verify Credit

Moderate

Goal

Starting from a Pro plan subscription, downgrade to Basic, verify the credit preview, confirm the change, and validate via the Stripe API that the subscription item now references the Basic price.

Playwright Implementation

downgrade-plan.spec.ts

6. Scenario: Update Payment Method

Updating a payment method in the Billing Portal involves an embedded Stripe Elements iframe inside the hosted portal page. This is the trickiest interaction to automate because you need to locate the iframe on Stripe's domain and fill card fields within it. The iframe is rendered by Stripe.js and contains separate input fields for card number, expiry, and CVC, similar to the standalone Stripe Elements experience but nested one level deeper inside the portal page.

Payment Method Update Flow

🌐

Portal Page

billing.stripe.com

💳

Click Update

Payment method section

📦

Elements iframe

Card input fields

🔒

Fill Card

4242...4242

⚙️

Submit

SetupIntent confirmed

🔔

Webhook

setup_intent.succeeded

Updated

New default method

4

Update Payment Method in Portal

Complex

Goal

From the Billing Portal, update the payment method to a new test card, confirm the update succeeds, and verify via the Stripe API that the customer's default payment method has changed.

Playwright Implementation

update-payment-method.spec.ts

Update Payment Method: Playwright vs Assrt

test('update payment method', async ({ page, portalUrl }) => {
  await page.goto(portalUrl);
  await page.waitForURL(/billing\.stripe\.com/);
  await page.getByRole('button', { name: /update/i }).first().click();
  const cardFrame = page
    .frameLocator('iframe[src*="js.stripe.com"]')
    .first();
  await cardFrame.getByPlaceholder(/card number/i)
    .fill('5555555555554444');
  await cardFrame.getByPlaceholder(/mm \/ yy/i)
    .fill('12/29');
  await cardFrame.getByPlaceholder(/cvc/i).fill('123');
  await page.getByRole('button', { name: /save/i }).click();
  await expect(
    page.getByText(/your payment method has been updated/i)
  ).toBeVisible({ timeout: 15_000 });
});
47% fewer lines

7. Scenario: Webhook-Driven State Verification

Every Billing Portal action triggers webhooks. The portal UI confirms the action to the customer, but the real test is whether your backend processes the webhook correctly and updates your database. This scenario demonstrates a pattern for intercepting and verifying webhook delivery as part of your end-to-end test.

The key insight is that you need to poll your own application state after the portal interaction. Stripe delivers webhooks asynchronously, typically within a few seconds in test mode, but there is no guarantee of instant delivery. Your test must wait for the state change to propagate rather than asserting immediately after the portal redirect.

5

Verify Webhook Processing After Portal Action

Complex

Goal

After a subscription cancellation in the portal, verify that your backend received the webhook, processed it, and updated the customer record in your database. Use a polling pattern to handle the async delay.

Playwright Implementation

webhook-verification.spec.ts
Test Run: Webhook Verification

8. Scenario: Test Clock Manipulation for Billing Cycles

Real subscription billing involves time-dependent events: trial expirations, monthly renewals, past-due retries, and grace period endings. You cannot wait 30 days in CI to test a renewal. Stripe Test Clocks solve this by letting you create customers attached to a virtual clock, then advance that clock forward programmatically. When you advance the clock, Stripe processes all billing events that would have occurred in that time span, including invoice creation, payment attempts, and subscription status changes.

6

Test Clock: Trial Expiration to Active

Complex

Goal

Create a customer on a test clock with a 14-day trial, advance the clock past the trial end, verify that Stripe charges the customer and the subscription transitions from trialing to active, and confirm your application reflects the change.

Playwright Implementation

test-clock-trial.spec.ts

Test clocks are powerful but have limitations. A test clock can only move forward, never backward. Customers attached to a test clock cannot be used in the Billing Portal UI (they are API-only). Test clocks are purely for backend billing logic verification. For portal UI testing, use the regular fixtures from Section 2.

9. Common Pitfalls That Break Billing Portal Test Suites

These are real problems reported in Stripe community forums, GitHub issues, and Stack Overflow threads. Each one has broken production test suites.

Billing Portal Testing Anti-Patterns

  • Sharing a single Stripe customer across tests. Parallel tests mutate the same subscription, causing random failures. Always create a fresh customer per test.
  • Asserting UI state before the webhook has been processed. The portal redirect happens before your backend updates. Use the polling pattern from Section 7 with toPass().
  • Hardcoding Stripe Element iframe selectors. Stripe updates their hosted page DOM without notice. Use frameLocator with attribute selectors like iframe[src*="js.stripe.com"] instead of positional selectors.
  • Forgetting to start the Stripe CLI listener before running tests. Without webhook forwarding, your backend never receives events and all state assertions fail silently.
  • Using real card numbers or live mode keys in test fixtures. Stripe test mode cards (4242..., 5555...) must be used exclusively. Live mode API calls will create real charges.
  • Not cleaning up test customers after test runs. Stripe test mode has object limits. Failing to delete test customers accumulates orphaned data.
  • Setting navigationTimeout too low. The portal redirect chain (your app, Stripe API, billing.stripe.com, back to your app) can take 5 to 10 seconds in CI. Use at least 30 seconds.
  • Testing proration math in end-to-end tests. Proration amounts change based on time of month. Assert that a proration preview exists and contains a dollar amount, not a specific value.
Common Failure: Webhook Not Received

10. Writing These Scenarios in Plain English with Assrt

The Playwright implementations above are thorough but verbose. Every cross-origin navigation, iframe interaction, and webhook polling pattern adds boilerplate that obscures the actual business intent. Assrt lets you describe these same scenarios in plain English. The framework compiles your scenario files into the same Playwright TypeScript you would write by hand, with the correct frameLocator calls, waitForURL patterns, and toPass polling built in.

Full Cancel Flow: Playwright vs Assrt

test('cancel subscription via billing portal', async ({
  page, portalUrl, activeSubscription,
}) => {
  await page.goto(portalUrl);
  await page.waitForURL(/billing\.stripe\.com/);
  await page.getByRole('button', { name: /cancel plan/i }).click();
  await page.getByText(/are you sure/i).waitFor();
  await page.getByLabel(/too expensive/i).check();
  await page.getByRole('button', { name: /cancel plan/i }).click();
  await expect(
    page.getByText(/your plan has been canceled/i)
  ).toBeVisible({ timeout: 10_000 });
  await page.getByRole('link', { name: /return/i }).click();
  await page.waitForURL(/\/billing/);
  await expect(
    page.getByText(/your subscription will end/i)
  ).toBeVisible({ timeout: 15_000 });
});
56% fewer lines

Here is a complete .assrt file that covers the three core billing portal scenarios from this guide. Each scenario block compiles independently into a full Playwright test with all the fixtures, iframe handling, and webhook polling built in.

# billing-portal.assrt
# Stripe Billing Portal end-to-end scenarios

config:
  base_url: http://localhost:3000
  fixtures:
    - stripe-customer
    - active-subscription
    - portal-session

---
scenario: Cancel subscription via billing portal
steps:
  - open the billing portal
  - click "Cancel plan"
  - select "Too expensive" as the reason
  - confirm the cancellation
expect:
  - "your plan has been canceled" is visible in the portal
  - after returning to the app, the billing page shows "your subscription will end"
  - the Stripe subscription has cancel_at_period_end set to true

---
scenario: Upgrade from Basic to Pro
steps:
  - open the billing portal
  - click "Update plan"
  - select the "Pro" plan
  - verify a proration preview with a dollar amount is shown
  - click "Confirm"
expect:
  - "your plan has been updated" is visible
  - after returning to the app, "Pro plan" is displayed
  - the Stripe subscription item references the Pro price ID

---
scenario: Update payment method to Mastercard
steps:
  - open the billing portal
  - click "Update" on the payment method section
  - fill card number with "5555555555554444"
  - fill expiry with "12/29"
  - fill CVC with "123"
  - click "Save"
expect:
  - "your payment method has been updated" is visible
  - the Stripe customer default payment method ends in "4444"

Assrt compiles each scenario block into the same Playwright TypeScript you saw in the preceding sections, committed to your repo as real tests you can read, run, and modify. When Stripe updates the Billing Portal DOM structure (which happens without notice), Assrt detects the failure, analyzes the new page, and opens a pull request with updated locators. Your scenario files remain unchanged.

Start with the cancel subscription flow. Once it is green in your CI, add the upgrade scenario, then the payment method update, then the downgrade with proration verification. In a single afternoon you can have complete Billing Portal coverage that most SaaS applications never achieve 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