How to Test Free Trial to Paid Conversion
Short answer
Trial conversion is core SaaS revenue. E2E must cover trial expiry without payment method, mid-trial upgrade with proration, and feature lock after grace—using seeded trial_end, Stripe test clocks, and entitlement probes on APIs—not banner copy alone.
Part of Testing Guides by business flow.
Who this is for
SaaS with time-limited trials, freemium upsell, or card-required trials who need reliable conversion tests without waiting two weeks in CI.
Why testing trial conversion matters
- ARR — silent lock without upgrade path churns; accidental charge without consent creates disputes.
- Product — trial users hit limits; upgrade modal must not block export/delete.
- Support — "trial expired but still charged" from clock skew between app and Stripe.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| No card on file | Trial ends | Data loss fear untested | Probe grace + export access |
| Card on file | Trial ends | Auto-charge fails | Decline card at conversion |
| Mid-trial upgrade | Proration | Wrong invoice | Probe invoice + entitlements |
| Feature lock | Day after expiry | API still 200 | Probe 403 on premium API |
| Grace period | 3-day grace | Lock too early | Seed trial_end + grace config |
| Trial extension | Admin extends | Stale UI | Probe trial_end updated |
| Downgrade during trial | Allowed? | Wrong state | Probe plan remains trial |
| Team trial | Seat limits | Invite blocked | Link seat licensing |
| Coupon on convert | First invoice | Discount missing | Probe invoice discount |
| Cancel during trial | No charge | Sub canceled | Probe status trialing→canceled |
| Timezone | UTC vs local expiry | Off-by-one | Probe ISO trial_end |
| Re-trial abuse | Same email | Second trial | Probe policy rejection |
Trial without payment method
await request.post('/api/test/seed-trial-user', {
data: { runId, trialDaysRemaining: 0, paymentMethod: null },
});
const ent = await request.get(`/api/test/probes/entitlements/${runId}`).then(r => r.json());
expect(ent.trialState).toBe('expired');
expect(ent.canAccessPremium).toBe(false);
const api = await request.get('/api/v1/reports/advanced', {
headers: { Authorization: `Bearer ${ent.token}` },
});
expect(api.status()).toBe(403);
Trial → paid with test clock
const { clockId, userId } = await request.post('/api/test/seed-trial-user', {
data: { runId, trialDays: 14, attachClock: true, addPaymentMethod: true },
}).then(r => r.json());
await request.post('/api/test/advance-clock', { data: { clockId, to: 'trial_end' } });
await expect.poll(async () => {
const sub = await request.get(`/api/test/probes/subscriptions/${runId}`).then(r => r.json());
return sub.status;
}, { timeout: 45_000 }).toBe('active');
const ent = await request.get(`/api/test/probes/entitlements/${userId}`).then(r => r.json());
expect(ent.canAccessPremium).toBe(true);
Mid-trial upgrade
await request.post('/api/test/seed-trial-user', {
data: { runId, plan: 'pro_trial', trialDaysRemaining: 7 },
});
await page.goto('/settings/upgrade');
await page.getByRole('button', { name: 'Upgrade to Enterprise' }).click();
await completeStripeCheckout(page, runId);
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);
See subscriptions billing for proration detail.
UX vs API Assert
Upgrade prompts and modals are optional UI Assert—premium API 403 and subscription probe are mandatory.
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Wait 14 days | Never runs | Clock or past trial_end seed |
| UI "Trial expired" only | API open | Probe entitlements |
| Skip no-card path | Common prod segment | Dedicated scenario |
| No grace test | Angry users | Probe grace flags |
Example scenario
Situation: Trial expires; user has no payment method on file.
Expected outcome: Premium API locked after grace; data export still allowed per policy; upgrade flow collects PM.
Why UI-only automation breaks: Banner shows expired but API returns 200 for premium routes.
- Arrange: Seed user with trial_end in past, no PM, grace_days=3 if applicable.
- Act: Call premium API; attempt upgrade checkout.
- Assert: Probe 403 then 200 after successful upgrade and subscription active.
TestChimp workflow: Track trial_state × plan_tier in TrueCoverage after pricing experiments.
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 annotations for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).
Related scenarios
- Subscriptions billing — clocks, dunning
- Feature entitlements — API gates
- SaaS onboarding — activation funnel
External references
Frequently asked questions
How do I test trial expiry without waiting two weeks?
Seed user with trial_end in past or use Stripe test clock to advance billing period, then assert feature lock and upgrade flow via probes.
Should I test trial conversion with and without a card on file?
Yes—both are common prod paths. No-card tests grace and upgrade PM collection; with-card tests auto-conversion and decline at trial end.
How do mid-trial upgrades differ from post-trial conversion?
Mid-trial may prorate immediately; probe invoice lines and entitlements upgrade before trial_end. Post-trial creates new active subscription from expired state.
What about data retention after trial expiry?
Probe export endpoints and deletion policy flags—product-specific but often regulated; do not only assert marketing copy on pricing page.
Can I use the same trial user in parallel workers?
No—seed per runId with unique user and Stripe customer metadata e2e_run.
Feature lock vs read-only mode?
Probe each premium endpoint your product defines; some allow read-only analytics—encode in entitlement matrix tests.
Which trial exit paths happen in production?
Compare trial_state × plan_tier in TrueCoverage after pricing experiments. Run /testchimp evolve for missing conversion paths with scenario annotation links.
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.