DEV Community

Cover image for How We Test Stripe Billing Cycles in 5 Minutes (Instead of Waiting 40 Days)
Paul Towers
Paul Towers

Posted on

How We Test Stripe Billing Cycles in 5 Minutes (Instead of Waiting 40 Days)

If you've ever shipped a billing feature and hoped it "just works," this one's for you.


🧪 "How do we test billing?" — the dev's existential question

You’ve just implemented a $250/month Pro plan with a 7-day trial. QA walks in with that dreaded question:

"So... how do we actually test the full billing lifecycle?"

And you freeze.

Let’s break down your options:

  • Wait real-time (7+ days, then another 30 for the next cycle 😴)
  • Hack timestamps in the DB (super brittle, breaks webhooks)
  • Mock Stripe (fast but misses real-world behavior)
  • Ship and pray (don't pretend you've never done this 😅)

None of these are great. Stripe’s real billing cycles take actual time, which doesn’t work for CI/CD or fast iteration.

So we found a better way.


🧠 TL;DR – The Stack That Made It Work

We built a fast, repeatable billing test suite using:

  • Stripe Test Clocks to simulate time
  • Playwright for full E2E coverage (including UI + webhooks)
  • ✅ A backend API to manage test clocks safely
  • ✅ TypeScript test helpers to drive everything

Complete billing lifecycle (trial → payment → renewal) verified in under 5 minutes.


The Dev Setup: Stripe Test Clocks + Playwright

Here’s how the system is structured:

1. Backend API (Node.js / TypeScript)

  • Safely creates and advances Stripe Test Clocks
  • Restricts operations to test / dev environments
  • Orchestrates subscription creation + webhook coordination

2. Frontend Helpers

  • TypeScript utilities for tests
  • Wrap API calls to create/advance clocks
  • Validate subscription state + invoices

3. Playwright E2E Tests

  • Simulate real user upgrade via UI
  • Trigger Stripe checkout flows
  • Advance time + verify billing events

🧩 Why Test Clocks Matter

Stripe's Test Clocks let you:

  • Create a "sandboxed" version of time
  • Attach resources (customers, subscriptions) to that clock
  • Move time forward (e.g. +7 days, +30 days)
  • Observe how billing behaves across time boundaries

Perfect for CI, bug reproduction, or validating complex billing edge cases.


🛠️ Code Highlights: How It Works

Create a Test Clock

export const createTestClock = async (frozenTime: Date) => {
  ensureTestEnvironment(); // Safety first
  return await stripe.testHelpers.testClocks.create({
    frozen_time: Math.floor(frozenTime.getTime() / 1000),
    name: `billing-test-${Date.now()}`,
  });
};
Enter fullscreen mode Exit fullscreen mode

✅ Only allowed in test/dev environments (we enforce this in every layer)

Advance Time & Wait for Webhooks

Advancing time isn't enough — you also need to wait for Stripe to process webhooks:

export const advanceTestClockAndWait = async (
  testClockId: string,
  targetTime: string
) => {
  await stripe.testHelpers.testClocks.advance(testClockId, {
    frozen_time: Math.floor(new Date(targetTime).getTime() / 1000),
  });

  // Poll until webhooks are processed
  await waitForWebhookProcessing(testClockId);
};

Enter fullscreen mode Exit fullscreen mode

Billing Verification Logic

You’ll want to verify:

  • Subscription status (trialing, active, etc)
  • Invoices generated + paid
  • Accurate amounts ($0 trial invoice vs $250 active)
export const verifyBillingLifecycle = async (
  page: Page,
  customerId: string,
  subscriptionId: string
) => {
  const response = await page.request.post('/api/v1/billing/test/verify', {
    data: { customerId, subscriptionId },
  });

  return response.json();
};

Enter fullscreen mode Exit fullscreen mode

🧪 Real-World Example: Trial → Payment → Renewal

Let’s walk through a full billing cycle test in Playwright.

1. Create a frozen time

const frozenTime = new Date();
frozenTime.setHours(0, 0, 0, 0);

Enter fullscreen mode Exit fullscreen mode

2. Set up the environment

const testEnv = await createTestClockEnvironment(page, accountId, 'pro', 'monthly', frozenTime.toISOString());

Enter fullscreen mode Exit fullscreen mode

3. User upgrades to Pro via the UI

await subscriptionPage.upgradeToProPlan('monthly');
await completeStripeCheckout(page, STRIPE_TEST_CARDS.VISA_SUCCESS);

Enter fullscreen mode Exit fullscreen mode

4. Verify trial state

const result = await verifyBillingLifecycle(
  page,
  testEnv.customer.id,
  testEnv.subscription.id,
);
expect(result.subscription.status).toBe('trialing');

Enter fullscreen mode Exit fullscreen mode

5. Advance 8 days (past trial)

await advanceTestClockAndWaitForWebhooks(
  page,
  testEnv.testClock.id,
  addDays(frozenTime, 8).toISOString()
);

Enter fullscreen mode Exit fullscreen mode

6. Verify first charge ($250)

const invoices = await verifyBillingLifecycle(...);
expect(invoices.total).toBeGreaterThan(0);

Enter fullscreen mode Exit fullscreen mode

7. Advance 30 more days → Second billing

await advanceTestClockAndWaitForWebhooks(
  page,
  testEnv.testClock.id,
  addDays(frozenTime, 40).toISOString()
);

Enter fullscreen mode Exit fullscreen mode

8. Verify second charge ($250 again)

const updated = await verifyBillingLifecycle(...);
expect(updated.invoices.details.length).toBe(2);

Enter fullscreen mode Exit fullscreen mode

Total test runtime: < 5 minutes
Equivalent real-world time: 40+ days

⚠️ Lessons Learned (The Hard Way)

✅ Environment Isolation is Critical

We added ensureTestEnvironment() checks to every function. You do not want test clocks running in prod.

✅ Webhook Processing Needs Polling

Fixed delays didn’t cut it. We built a polling system to check webhook readiness before continuing.

✅ Clean Up or Get Cluttered

Test clocks + subscriptions pile up fast. We auto-clean on teardown and provide manual cleanup endpoints.

🚀 Why This Matters

Before:

  • Manual testing in dev
  • No way to test full cycle before prod

After:

  • 99.99% faster test runs
  • Full E2E coverage (trial, charge, renewal)
  • Same tests run in CI and local
  • No surprises in production

🧼 Cleanup FTW

We cancel test subscriptions and delete clocks automatically:

export const cleanupTestClockEnvironment = async (testClockId) => {
  const subs = await getTestClockSubscriptions(testClockId);
  for (const s of subs) await stripe.subscriptions.cancel(s.id);
  await stripe.testHelpers.testClocks.del(testClockId);
};

Enter fullscreen mode Exit fullscreen mode

Hook it into your test teardown:

test.afterAll(async () => {
  await cleanupTestClockEnvironment(testEnvironment.testClock.id);
});

Enter fullscreen mode Exit fullscreen mode

Final Thoughts: Test Billing Like a Dev

This isn’t about gold-plating tests. It’s about developer confidence.

With Stripe Test Clocks + E2E automation, we stopped shipping billing features and crossing our fingers. Now we know the entire lifecycle works before anything hits prod.

  • CI-safe
  • Fully deterministic
  • Validates actual money movement

If you're building a subscription product and not testing like this... you probably should be.

What’s Your Billing Test Strategy?

Are you using test clocks? Mocking everything? Still running manual tests in staging?

Let’s trade notes, drop a comment with how you're handling this today.

Top comments (0)