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()}`,
});
};
✅ 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);
};
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();
};
🧪 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);
2. Set up the environment
const testEnv = await createTestClockEnvironment(page, accountId, 'pro', 'monthly', frozenTime.toISOString());
3. User upgrades to Pro via the UI
await subscriptionPage.upgradeToProPlan('monthly');
await completeStripeCheckout(page, STRIPE_TEST_CARDS.VISA_SUCCESS);
4. Verify trial state
const result = await verifyBillingLifecycle(
page,
testEnv.customer.id,
testEnv.subscription.id,
);
expect(result.subscription.status).toBe('trialing');
5. Advance 8 days (past trial)
await advanceTestClockAndWaitForWebhooks(
page,
testEnv.testClock.id,
addDays(frozenTime, 8).toISOString()
);
6. Verify first charge ($250)
const invoices = await verifyBillingLifecycle(...);
expect(invoices.total).toBeGreaterThan(0);
7. Advance 30 more days → Second billing
await advanceTestClockAndWaitForWebhooks(
page,
testEnv.testClock.id,
addDays(frozenTime, 40).toISOString()
);
8. Verify second charge ($250 again)
const updated = await verifyBillingLifecycle(...);
expect(updated.invoices.details.length).toBe(2);
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);
};
Hook it into your test teardown:
test.afterAll(async () => {
await cleanupTestClockEnvironment(testEnvironment.testClock.id);
});
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)