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: 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: 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.