Testing Stripe Subscriptions and Billing: Proration, Upgrades, and Cancellation
Stripe subscription testing requires simulating billing cycles, plan changes, and edge cases that only appear over time. Stripe's test mode lets you fast-forward time, trigger immediate renewals, and test proration calculations without waiting for real billing cycles. This guide covers how to test subscription creation, upgrades, cancellations, trial periods, and failed renewals.
Key Takeaways
Use test clocks to simulate time. Stripe test clocks let you advance time in test mode without waiting. Fast-forward to the trial end date, renewal date, or billing period boundary to trigger the events you want to test.
Proration happens server-side — test the amount, not your calculation. When you upgrade a subscription mid-period, Stripe calculates proration automatically. Your test should assert the resulting invoice amount, not re-implement Stripe's proration math.
Test the failed renewal scenario. A card that works for initial payment may decline on renewal. Use pm_card_chargeDeclinedInsufficientFunds for the renewal payment method and verify your dunning logic triggers.
Test "cancel at period end" vs "cancel immediately." These two cancellation modes have very different user experience implications. Most teams only test one and discover the other in production when a customer complains.
Subscription webhooks have more event types than payment webhooks. customer.subscription.updated, invoice.payment_failed, customer.subscription.deleted — all require separate handling and testing.
Why Subscription Testing Is Complex
One-time payments are simple: card charged, webhook fires, order created. Subscriptions involve:
- Recurring billing — the same card charged monthly/yearly
- Plan changes — upgrades and downgrades with proration
- Trial periods — free access followed by the first charge
- Failed renewals — the dunning cycle when renewal payments fail
- Cancellations — immediate or at period end
- Reactivations — canceled subscriptions brought back to active
Each of these generates different webhook events and requires different handling logic. Testing all of them manually at real billing intervals would take months. Stripe's test tools compress this to minutes.
Test Setup
import stripe
import pytest
stripe.api_key = "sk_test_your_test_key"
@pytest.fixture
def test_customer():
"""Create a test customer with a working payment method."""
customer = stripe.Customer.create(
email="test@example.com",
payment_method="pm_card_visa",
)
stripe.Customer.modify(
customer.id,
invoice_settings={"default_payment_method": "pm_card_visa"},
)
yield customer
# Cleanup
stripe.Customer.delete(customer.id)
@pytest.fixture
def price_monthly():
"""Get or create a monthly subscription price."""
# In real tests, use price IDs from your Stripe test configuration
return "price_test_monthly_basic"Testing Subscription Creation
def test_subscription_creation(test_customer, price_monthly):
sub = stripe.Subscription.create(
customer=test_customer.id,
items=[{"price": price_monthly}],
)
assert sub.status == "active"
assert len(sub.items.data) == 1
assert sub.items.data[0].price.id == price_monthly
assert sub.current_period_end > sub.current_period_start
# Verify initial invoice was created and paid
invoices = stripe.Invoice.list(customer=test_customer.id)
first_invoice = invoices.data[0]
assert first_invoice.status == "paid"
assert first_invoice.subscription == sub.idTesting Subscription Upgrades with Proration
When a customer upgrades from a basic to a pro plan mid-billing-cycle, Stripe creates a proration invoice. Test that the correct amount is charged:
def test_upgrade_creates_proration_invoice(test_customer):
# Start on basic plan
sub = stripe.Subscription.create(
customer=test_customer.id,
items=[{"price": "price_test_monthly_basic"}],
)
assert sub.status == "active"
# Upgrade to pro plan immediately
updated_sub = stripe.Subscription.modify(
sub.id,
items=[{
"id": sub.items.data[0].id,
"price": "price_test_monthly_pro",
}],
proration_behavior="create_prorations",
)
assert updated_sub.status == "active"
assert updated_sub.items.data[0].price.id == "price_test_monthly_pro"
# Proration invoice should be created
invoices = stripe.Invoice.list(customer=test_customer.id, limit=5)
proration_invoice = next(
(inv for inv in invoices.data if inv.billing_reason == "subscription_update"),
None,
)
assert proration_invoice is not None
assert proration_invoice.status in ("paid", "open")
def test_upgrade_preview_proration():
"""Preview the proration amount before actually upgrading."""
# This is useful in the frontend to show "you'll be charged $X today"
upcoming = stripe.Invoice.upcoming(
customer="cus_existing_customer",
subscription="sub_existing",
subscription_items=[{
"id": "si_existing_item",
"price": "price_test_monthly_pro",
}],
)
# The upcoming invoice shows what the customer will be charged
assert upcoming.amount_due >= 0Testing Trial Periods with Test Clocks
Trial periods are where test clocks become essential. Instead of waiting 14 days for a trial to end, advance the test clock:
def test_trial_converts_to_paid(test_customer):
# Create a test clock (test mode only)
test_clock = stripe.test_helpers.TestClock.create(
frozen_time=int(time.time()),
)
# Create customer attached to the test clock
customer = stripe.Customer.create(
email="trial@example.com",
payment_method="pm_card_visa",
test_clock=test_clock.id,
)
stripe.Customer.modify(
customer.id,
invoice_settings={"default_payment_method": "pm_card_visa"},
)
# Create subscription with 14-day trial
sub = stripe.Subscription.create(
customer=customer.id,
items=[{"price": "price_test_monthly_pro"}],
trial_period_days=14,
)
assert sub.status == "trialing"
assert sub.trial_end is not None
# Advance the test clock past the trial end
stripe.test_helpers.TestClock.advance(
test_clock.id,
frozen_time=int(time_clock.frozen_time) + (15 * 24 * 60 * 60), # +15 days
)
# Wait for the clock to advance (async operation)
import time as time_module
for _ in range(10):
clock = stripe.test_helpers.TestClock.retrieve(test_clock.id)
if clock.status == "ready":
break
time_module.sleep(0.5)
# Retrieve updated subscription
updated_sub = stripe.Subscription.retrieve(sub.id)
assert updated_sub.status == "active" # Trial ended, now active
# Check that the first invoice was created
invoices = stripe.Invoice.list(customer=customer.id)
assert any(inv.billing_reason == "subscription_cycle" for inv in invoices.data)
# Cleanup
stripe.test_helpers.TestClock.delete(test_clock.id)Testing Failed Renewal Payments
The dunning cycle — what happens when a renewal payment fails — is one of the most important and undertested flows:
def test_failed_renewal_payment():
# Create customer with a card that will decline on charge
customer = stripe.Customer.create(
email="dunning@example.com",
payment_method="pm_card_chargeDeclinedInsufficientFunds",
)
stripe.Customer.modify(
customer.id,
invoice_settings={
"default_payment_method": "pm_card_chargeDeclinedInsufficientFunds"
},
)
# Create a subscription
try:
sub = stripe.Subscription.create(
customer=customer.id,
items=[{"price": "price_test_monthly_basic"}],
)
except stripe.error.CardError:
# Initial payment failed — verify your signup error handling
pytest.skip("Test requires separate handling of initial payment failure")
# For testing renewal failure specifically, create a subscription
# with a trial, then let the trial end with a bad card
# (or use test clocks to simulate the renewal cycle)
# Alternatively, directly test the invoice payment failure webhook
# by constructing a signed payload (see webhook testing guide)Testing Cancellation
def test_cancel_at_period_end(test_customer, price_monthly):
sub = stripe.Subscription.create(
customer=test_customer.id,
items=[{"price": price_monthly}],
)
assert sub.status == "active"
# Cancel at period end (customer keeps access until billing period ends)
canceled_sub = stripe.Subscription.modify(
sub.id,
cancel_at_period_end=True,
)
# Status is still active, but cancel_at_period_end is True
assert canceled_sub.status == "active"
assert canceled_sub.cancel_at_period_end is True
assert canceled_sub.cancel_at is not None
# Customer should still have access at this point
# Your app should check cancel_at_period_end to show "cancels on [date]" UI
def test_cancel_immediately(test_customer, price_monthly):
sub = stripe.Subscription.create(
customer=test_customer.id,
items=[{"price": price_monthly}],
)
assert sub.status == "active"
# Cancel immediately
canceled_sub = stripe.Subscription.delete(sub.id)
# Status is now canceled
assert canceled_sub.status == "canceled"
# customer loses access immediately
def test_reactivate_subscription(test_customer, price_monthly):
sub = stripe.Subscription.create(
customer=test_customer.id,
items=[{"price": price_monthly}],
)
# Schedule cancellation
stripe.Subscription.modify(sub.id, cancel_at_period_end=True)
# Customer changes their mind — reactivate
reactivated = stripe.Subscription.modify(
sub.id,
cancel_at_period_end=False,
)
assert reactivated.status == "active"
assert reactivated.cancel_at_period_end is FalseWebhook Events to Test for Subscriptions
Each subscription state change fires a webhook. Test your handler for all of these:
| Event | When it fires | What to test |
|---|---|---|
customer.subscription.created |
New subscription | Welcome email queued, access granted |
customer.subscription.updated |
Plan change | Access level updated, proration invoice |
customer.subscription.deleted |
Canceled immediately | Access revoked |
invoice.payment_succeeded |
Renewal paid | Access renewed, receipt sent |
invoice.payment_failed |
Renewal declined | Dunning email queued, access warning |
customer.subscription.trial_will_end |
3 days before trial ends | Reminder email |
Write a webhook handler test for each of these. The invoice.payment_failed test is especially important — verify that your dunning email goes to the queue and that access isn't immediately revoked (most apps give a grace period).
Organizing Subscription Tests
tests/
payments/
test_subscription_creation.py
test_subscription_upgrades.py
test_subscription_cancellation.py
test_subscription_trials.py
test_subscription_webhooks.py
test_dunning_flow.py
conftest.py # shared fixturesKeep subscription tests in separate files by scenario. Subscription tests are slow (they create real Stripe objects) — mark them with @pytest.mark.integration and run them in a separate CI step.
Summary
Stripe subscription testing requires covering more scenarios than one-time payment tests. The essential toolkit is test clocks (for trial periods and renewal cycles), proration preview (for upgrade pricing UI), and a systematic approach to webhook handler testing for each event type. Write tests for cancellation modes separately — "cancel at period end" and "cancel immediately" have different UX implications and different bugs.