Skip to main content

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

ScenarioEdge caseWhy tests breakApproach
Invite link ?ref=Query stripped on redirectLost attributionAssert final URL preserves ref
Cookie attribution7-day windowExpired in long testSeed clock or shorten TTL in test env
Double redemptionReferee signs up twiceDuplicate creditProbe ledger count = 1
Self-referralSame email +aliasFraud holeNegative spec
Team vs user inviteOrg-level codeWrong beneficiaryProbe referrer_org_id
Partial funnelSignup without paid conversionPremature rewardProbe on subscription.active
Cookie vs localStorageSafari ITPAttribution dropDocument storage; probe source
Parallel CIShared referral code WELCOMECollisionPer-run code from seed
Email inviteToken in inboxCI inbox neededTransactional email guide
Deep link mobileApp handoff untestedWeb-only greenDocument platform gap
Cap limitsMax 10 referrals/monthUntested boundarySeed 10 prior events
Currency rewardsUSD referrer, EUR refereeWrong payoutProbe currency column
Revoke on refundChargeback clawbackOverpaid referrerWebhook + probe reversal
Graph invite treeMulti-level MLMWrong parentProbe parent_referral_id
GDPR deleteReferrer deletedOrphan ledgerProbe 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 });
}

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

TypeTypical triggerProbe
Email team inviteAccept tokenorg_members row
Public referral linkSignup + conversionreferral_ledger
Affiliate code at checkoutOrder paidorder.referral_code

See seat licensing for invite-to-org flows.

Anti-patterns

Anti-patternWhy it failsBetter approach
Assert dashboard "1 referral" labelDenormalized counter driftProbe ledger
Same browser profile referrer+refereeSession overwriteTwo contexts
Global promo code in stagingParallel collisionPer-run ref-${runId}
Skip self-referral testFraud in prodNegative seed same user
Reward on signup onlyChargeback riskProbe on paid conversion
No audit on expired inviteUX liesProbe token expired

External references

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.

  1. Arrange: Seed referrer with run-scoped code; enable E2E_TEST_MODE attribution logging.
  2. Act: New browser context opens invite URL; referee signs up and converts to paid.
  3. 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.

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.

Start free on TestChimp · Book a demo