Skip to main content

How to Test Subscription Billing, Trials, and Renewals

Short answer

Subscription state lives in Stripe and your app—UI "Active" badges lie when invoice.payment_failed left the account past_due, or when webhooks lag after plan change. Test billing with Stripe test mode, test clocks for time travel, webhook probes on customer.subscription.* and invoice.* events, and entitlement Assert on APIs. Seed mid-cycle subscriptions via API Arrange; never wait 14-day trials in CI.

Part of Testing Guides by business flow.

Who this is for

B2B and B2C SaaS teams on Stripe Billing (or similar) who need Playwright E2E covering trial signup, renewal, upgrade/downgrade with proration, failed payment dunning, and cancellation—not a single "subscribe with 4242" spec.

Typical stacks: Next.js + Stripe Customer Portal, Rails with Pay gem, Laravel Cashier, or custom billing dashboards backed by Stripe webhooks.

Why testing subscription billing matters

Billing bugs hit ARR directly:

  • Revenue leakage — trial users retain premium API access after trial_end; downgrades not applied until manual support intervention.
  • Involuntary churn — dunning emails never sent because invoice.payment_failed handler throws; card update flow broken on 3DS renewal.
  • Disputes — proration math wrong on mid-cycle upgrade; customer charged full new plan without credit.
  • Compliance — cancellation must honor access until period end; immediate lock without consent creates chargeback risk.

Client-side Stripe.js may show subscription active while your database still has plan: free until webhook processing—probe both sides.

Complexity map

ScenarioEdge caseWhy tests breakApproach
Trial signup14-day wait in CIUntested conversionTest clock or seed trial_end in past
Trial without PMEnd of trialSilent lock vs graceProbe entitlements + billing portal prompt
Renewalinvoice.paid webhook lagUI active, DB stalePoll probe subscription status
Failed renewalinvoice.payment_failedStill shows ActiveSeed decline card; probe past_due
DunningSmart Retries + emailsEmail untested in CIProbe dunning state; Mailtrap for copy (email guide)
Upgrade mid-cycleProration invoiceWrong line itemsProbe invoice lines, not plan label
DowngradeAt period end vs immediateWrong effective dateProbe cancel_at_period_end + schedule
Quantity/seatsStripe quantity vs app seatsMismatchProbe seat count + subscription item qty
Test clockFrozen timeReal clock advances in CIAttach customer to clock; advance via API
Coupon / promoOne-time vs foreverWrong durationSeed coupon; probe discount on invoice
Tax on subscriptionStripe Tax enabledMissing tax linesProbe invoice tax; see tax guide
Cancel reactivateWin-back flowOrphan PIProbe subscription id stable or new
Webhook idempotencyDuplicate invoice.paidDouble creditHandler idempotent on event id
Portal deep linkReturn URLSession lostSeed login; probe after portal return
Metadata e2e_runParallel workersCross-cancel subsScope teardown to runId

Stripe test mode and test clocks

Use test mode only. For time-dependent billing, Stripe test clocks advance simulated time without waiting wall-clock days.

// Arrange — seed route creates clock + customer + subscription
// POST /api/test/seed-subscription
// Body: { runId, planId: 'price_pro_monthly', trialDays: 14, attachClock: true }

const { customerId, subscriptionId, clockId } = await request.post('/api/test/seed-subscription', {
data: { runId, planId: 'price_pro_monthly', trialDays: 14 },
}).then(r => r.json());

Advance clock via Stripe API in test-only route:

// POST /api/test/advance-clock { clockId, frozenTime: epochSeconds }
await request.post('/api/test/advance-clock', {
data: { clockId, advanceTo: 'trial_end_plus_1h' },
});

After advance, poll until webhooks processed—Stripe fires customer.subscription.updated, invoice.created, etc.

When not to use clocks: simple trial signup happy path can seed trial_end timestamp directly on subscription create without clock object—faster for PR CI.

Trial signup and conversion

FlowArrangeActAssert
Trial with cardSeed user; Checkout mode: subscriptionComplete Elements with 4242Probe trialing; entitlements unlocked
Trial without cardSeed trial user, no PMAccess premium featureProbe allowed until trial_end
Trial → paidAdvance clock or past trial_endAdd PM in portal or UIProbe active; invoice paid
Trial expired lockSeed past trial, no PMHit premium APIProbe 403 + upgrade CTA optional

Link to trial-to-paid guide for conversion UX specifics.

await expect.poll(async () => {
const res = await request.get(`/api/test/probes/subscriptions/${runId}`);
return (await res.json()).status;
}, { timeout: 30_000 }).toBe('trialing');

Renewals and invoice.paid

Renewal is webhook-driven. Test:

  1. Seed active subscription on monthly price (or use clock at period boundary).
  2. Advance clock to renewal time or trigger invoice.pay via test helpers.
  3. Assert probe: subscription active, new invoice row, entitlements unchanged.

Never assert only Customer Portal "Next payment date" label—probe Stripe subscription status mirrored in your DB.

Dunning and failed payments

Simulate failed renewal with decline test cards on subscription default PM, or update PM to 4000000000000002 before renewal.

// After failed invoice
await expect.poll(async () => {
const res = await request.get(`/api/test/probes/subscriptions/${runId}`);
const body = await res.json();
return { status: body.status, dunningStep: body.dunningStep };
}).toMatchObject({ status: 'past_due', dunningStep: expect.any(Number) });

Cover:

  • Feature lock policy (immediate vs grace period)—probe API, not banner alone
  • Card update flow restores active
  • Cancellation after max retries (canceled + access revoked)

Use Mailtrap for dunning email content in a separate job if copy matters; CI can probe state only.

Plan changes and proration

Mid-cycle upgrade is a common revenue bug:

  1. Arrange — seed subscription 15 days into monthly cycle via API.
  2. Act — upgrade Pro → Enterprise in UI or Portal.
  3. Assert — probe latest invoice for proration line items (credit + charge), subscription items reflect new price id.
const invoice = await request.get(`/api/test/probes/invoices/latest/${runId}`).then(r => r.json());
expect(invoice.lines.some(l => l.description?.includes('Unused time'))).toBe(true);
expect(invoice.lines.some(l => l.priceId === 'price_enterprise_monthly')).toBe(true);

Downgrade scenarios:

  • At period end — probe cancel_at_period_end or schedule phase; entitlements remain until date
  • Immediate — probe credit and reduced access same day

Customer Portal and Checkout subscription mode

Checkout mode: 'subscription' — see Stripe payments guide for Elements/3DS on first invoice.

Customer Portal — redirect flow:

const { portalUrl } = await request.post('/api/billing/portal-session', {
data: { returnUrl: `${baseURL}/settings/billing` },
}).then(r => r.json());
await page.goto(portalUrl);
// Act inside Stripe-hosted portal — update plan, cancel, etc.
await page.getByRole('button', { name: 'Update plan' }).click();
// Return to app
await expect(page).toHaveURL(/settings\/billing/);
// Assert via probe

Portal iframes change—prefer completing portal actions with generous timeouts and always probe subscription state after return.

Webhook events to cover

Minimum handler coverage in E2E (via probe side effects):

EventAssert
customer.subscription.createdDB row + entitlements
customer.subscription.updatedPlan tier change reflected
customer.subscription.deletedAccess revoked per policy
invoice.paidRenewal recorded; usage reset if applicable
invoice.payment_failedpast_due + dunning step
checkout.session.completedInitial sub + customer link

Run Stripe CLI in CI when testing handler wiring: stripe listen --forward-to ...

Entitlements sync

Subscription status must map to feature entitlements:

// After upgrade probe
const ent = await request.get(`/api/test/probes/entitlements/${userId}`).then(r => r.json());
expect(ent.features).toContain('advanced_reports');

UI hiding premium nav without API 403 is a security gap—test both.

CI teardown

Subscriptions accumulate in test mode. Cancel by metadata:

for await (const sub of stripe.subscriptions.list({ status: 'all' })) {
if (sub.metadata?.e2e_run === process.env.RUN_ID) {
await stripe.subscriptions.cancel(sub.id);
}
}

Delete test clocks in teardown to avoid Stripe test object limits.

Anti-patterns

Anti-patternWhy it failsBetter approach
Wait 14 days for trial endNever runs in CITest clock or seed past trial_end
Assert "Active" badgeWebhook lagPoll subscription probe
Skip payment_failedSilent churn in prodDecline card renewal scenario
Upgrade test without proration checkRevenue bugsProbe invoice lines
Shared subscription per staging userParallel corruptionPer-run customer + metadata
Mock Billing in E2EMiss webhook bugsTest mode + real API
No teardownDashboard noiseCancel by e2e_run

Example scenario

Situation: A subscriber's renewal card declines on the monthly invoice.

Expected outcome: Account moves to past_due, dunning starts, premium API returns 403 after grace policy.

Why UI-only automation breaks: Billing page still shows Active while invoice.payment_failed was never handled.

  1. Arrange: Seed active subscription with PM 4000000000000002; advance test clock to renewal.
  2. Act: Wait for Stripe to attempt renewal (clock advance triggers invoice).
  3. Assert: Probe subscription past_due; probe premium API 403 after grace; optional dunning email in Mailtrap.

TestChimp workflow: Track plan_tier × billing_interval × dunning_state in TrueCoverage vs prod.

Same Arrange/Act/Assert pattern as expired-coupon checkout.

Connect scenarios to your QA workflow

Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).

External references

Frequently asked questions

How do I test trial-to-paid conversion without waiting 14 days?

Use Stripe test clocks to advance time, or seed subscriptions with trial_end in the past via API Arrange before UI Act. Poll probes after clock advance until webhooks settle.

Should subscription tests verify invoice webhooks?

Yes—UI may show active while invoice.payment_failed left the account past_due. Probe subscription status and handle invoice.paid / invoice.payment_failed events.

How do I test plan upgrades with proration?

Seed mid-cycle subscription via API, upgrade in UI or Portal, probe invoice line items for proration credits/charges—not just the new plan label.

How do I simulate dunning in CI?

Set default payment method to a decline test card, advance test clock to renewal, then probe past_due status and dunning step. Use Mailtrap for email copy in a separate job if needed.

Customer Portal or custom billing UI—which to test?

Test whichever prod users use—often both. Portal tests are redirect-heavy; always probe subscription state after return. Custom UI still depends on same webhooks.

How do I avoid parallel CI corrupting subscription tests?

Create per-run Stripe customers with metadata.e2e_run. Never share one staging subscriber across workers. Teardown cancels subs matching run id.

We have three plans and two billing intervals—how do we know all combos are tested?

Compare prod vs test distribution across plan_tier × billing_interval × dunning_state in TrueCoverage. When prod mix shifts, run /testchimp evolve after billing rule changes. Link SmartTests with // @Scenario: for rollup.

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