How to Test Referral and Invite Programs
Short answer
Referral programs fail quietly: attribution cookies expire, invite links work once in demo but double-redemption grants duplicate credits, and UI counters increment while the ledger never posts. Test invite flows with per-run referrers, isolated browser contexts for referee signups, cookie/storage assertions where needed, and probe Assert on referral_events or credit ledger rows—not dashboard vanity metrics alone.
Part of Testing Guides by business flow.
Who this is for
SaaS, fintech, and consumer apps with refer-a-friend, team invites, or affiliate codes who need Playwright E2E covering attribution windows, reward eligibility, fraud limits, and ledger truth.
Typical stacks: Stripe Customer Balance credits, internal wallet tables, promo code engines, or seat licensing with invite-gated growth.
Why referral testing matters
Referral bugs hit growth and margin:
- Double payout — same referee triggers two rewards on refresh or back-button
- Lost attribution — cookie blocked; referrer never credited
- Self-referral — same user agent or email alias bypasses rules
- Stale invites — expired token still shows success UI
- Support load — "I invited them but got nothing" with no audit trail in tests
E2E must assert ledger entries, not only that the invite modal shows "Sent!"
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
Invite link ?ref= | Query stripped on redirect | Lost attribution | Assert final URL preserves ref |
| Cookie attribution | 7-day window | Expired in long test | Seed clock or shorten TTL in test env |
| Double redemption | Referee signs up twice | Duplicate credit | Probe ledger count = 1 |
| Self-referral | Same email +alias | Fraud hole | Negative spec |
| Team vs user invite | Org-level code | Wrong beneficiary | Probe referrer_org_id |
| Partial funnel | Signup without paid conversion | Premature reward | Probe on subscription.active |
| Cookie vs localStorage | Safari ITP | Attribution drop | Document storage; probe source |
| Parallel CI | Shared referral code WELCOME | Collision | Per-run code from seed |
| Email invite | Token in inbox | CI inbox needed | Transactional email guide |
| Deep link mobile | App handoff untested | Web-only green | Document platform gap |
| Cap limits | Max 10 referrals/month | Untested boundary | Seed 10 prior events |
| Currency rewards | USD referrer, EUR referee | Wrong payout | Probe currency column |
| Revoke on refund | Chargeback clawback | Overpaid referrer | Webhook + probe reversal |
| Graph invite tree | Multi-level MLM | Wrong parent | Probe parent_referral_id |
| GDPR delete | Referrer deleted | Orphan ledger | Probe anonymization |
Seed pattern: referrer + invite token
// app/api/test/seed-referrer/route.ts
export async function POST(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { runId } = await req.json();
const code = `ref-${runId.slice(0, 8)}`;
await db.user.create({ data: { id: `user-ref-${runId}`, email: `ref+${runId}@test.local` } });
await db.referralCode.create({ data: { code, ownerId: `user-ref-${runId}`, runId } });
return NextResponse.json({ code, inviteUrl: `https://app.test/signup?ref=${code}` });
}
// app/api/test/probe-referral-ledger/route.ts
export async function GET(req: Request) {
const runId = new URL(req.url).searchParams.get('runId');
const { count, totalCents } = await db.referralLedger.aggregate({
where: { runId },
_count: true,
_sum: { amountCents: true },
});
return NextResponse.json({ count, totalCents: totalCents ?? 0 });
}
E2E: invite link → signup → reward
Use two browser contexts so referrer and referee sessions do not collide:
// @Scenario: growth/referral-first-paid-conversion
import { test, expect } from '../fixtures/run';
test('referrer credited when referee completes paid signup', async ({ browser, request, runId }) => {
const { inviteUrl, code } = await request.post('/api/test/seed-referrer', {
data: { runId },
}).then(r => r.json());
const refereeContext = await browser.newContext();
const refereePage = await refereeContext.newPage();
await refereePage.goto(inviteUrl);
await expect(refereePage).toHaveURL(new RegExp(`ref=${code}`));
await refereePage.getByLabel('Email').fill(`new+${runId}@test.local`);
await refereePage.getByLabel('Password').fill(`pw-${runId}`);
await refereePage.getByRole('button', { name: 'Create account' }).click();
await request.post('/api/test/simulate-paid-conversion', {
data: { runId, refereeEmail: `new+${runId}@test.local` },
});
await expect.poll(async () => {
const res = await request.get(`/api/test/probe-referral-ledger?runId=${runId}`);
return (await res.json()).count;
}).toBe(1);
await expect.poll(async () => {
const res = await request.get(`/api/test/probe-referral-ledger?runId=${runId}`);
return (await res.json()).totalCents;
}).toBe(1000);
await refereeContext.close();
});
Double-redemption negative test
test('second signup with same device does not double-credit', async ({ browser, request, runId }) => {
const { inviteUrl } = await seedReferrer(runId);
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto(inviteUrl);
await signup(page, `a+${runId}@test.local`, runId);
await simulatePaidConversion(request, runId);
await page.goto(inviteUrl);
await signup(page, `b+${runId}@test.local`, runId);
await simulatePaidConversion(request, runId);
await expect.poll(async () => {
const { count } = await probeLedger(request, runId);
return count;
}).toBe(1); // policy: one reward per referrer per attribution window
});
Adjust expected count to match your product rules—document in markdown plan.
Attribution storage checks
When business rules depend on cookies:
const cookies = await refereeContext.cookies();
const refCookie = cookies.find(c => c.name === 'tc_ref');
expect(refCookie?.value).toBe(code);
Prefer probe as source of truth; cookies are diagnostic when debugging lost attribution.
Team invites vs referral codes
| Type | Typical trigger | Probe |
|---|---|---|
| Email team invite | Accept token | org_members row |
| Public referral link | Signup + conversion | referral_ledger |
| Affiliate code at checkout | Order paid | order.referral_code |
See seat licensing for invite-to-org flows.
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Assert dashboard "1 referral" label | Denormalized counter drift | Probe ledger |
| Same browser profile referrer+referee | Session overwrite | Two contexts |
| Global promo code in staging | Parallel collision | Per-run ref-${runId} |
| Skip self-referral test | Fraud in prod | Negative seed same user |
| Reward on signup only | Chargeback risk | Probe on paid conversion |
| No audit on expired invite | UX lies | Probe token expired |
External references
- Playwright browser contexts
- MDN document.cookie
- SaaS onboarding patterns
- Seed routes and probe Assert
Example scenario
Situation: Referee completes signup via invite link but attribution cookie is stripped during OAuth redirect.
Expected outcome: Referrer receives exactly one ledger credit after referee paid conversion.
Why UI-only automation breaks: Referrer dashboard shows +1 invite while ledger count is zero.
- Arrange: Seed referrer with run-scoped code; enable E2E_TEST_MODE attribution logging.
- Act: New browser context opens invite URL; referee signs up and converts to paid.
- Assert: Probe referral_ledger count=1 and totalCents matches policy; optional cookie diagnostic.
TestChimp workflow: // @Scenario: growth/referral-attribution; TrueCoverage flags signup paths without ref param preserved.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Related
- Seat licensing and invites
- SaaS onboarding
- Transactional email
- Trial to paid conversion
- World-state mutation gotcha
Frequently asked questions
How do I test referral links without real email in CI?
Use URL-based ref codes for most E2E. For email invites, capture tokens via test inbox API or expose test-only token lookup route gated by E2E_TEST_MODE.
Should referrer and referee use the same Playwright test?
Yes, but separate browser contexts so cookies and sessions do not collide. One test proves end-to-end attribution and ledger posting.
How do I test attribution cookie expiry?
Shorten cookie TTL in test env, or seed server clock skew if supported. Assert probe shows no credit when referee converts after expiry.
What is the Assert source of truth for referrals?
Ledger or referral_events table via probe route—not UI counters, which can denormalize incorrectly or cache stale values.
How do I prevent self-referral in tests?
Negative spec: same user attempts own invite link. Probe ledger count stays zero and API returns documented error code.
Referral reward on signup vs first payment?
Match product policy in probe timing—simulate paid conversion if rewards require it. Document in markdown scenario to avoid false positives.
OAuth signup strips ref query param—how to test?
Assert ref preserved through redirect chain or stored server-side on first hit. Probe referrer_id on referee user row after OAuth callback.
TestChimp with referral programs?
/testchimp init scaffolds ledger probes; /testchimp test maintains multi-context specs when growth team changes signup UI or reward amounts.
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.