Mixpanel Testing Strategies: Events, People Properties, and Funnel Data Isolation
Mixpanel powers product analytics for funnels, retention, and segmentation. Testing Mixpanel integrations requires mocking the SDK, validating event schemas against your Lexicon, testing people property updates, and isolating test environments so CI events don't corrupt your production data. This guide covers all of this with practical Jest examples and Mixpanel-specific patterns.
Key Takeaways
Mixpanel's JavaScript SDK is stateful — mock it completely. mixpanel-browser maintains state (distinct IDs, super properties, people queues) across calls. Reset all state between tests using mockReset(), not just mockClear().
Super properties affect every event — test them explicitly. mixpanel.register() sets super properties that are merged into every subsequent track call. Bugs here corrupt your entire event stream. Test what super properties are set on init.
Test people.set() and people.set_once() separately. people.set overwrites; people.set_once doesn't. Using the wrong one for subscription plan (e.g., set_once for a property that should update on upgrade) means your cohort analysis is wrong.
Use distinct_id patterns for test data isolation. In integration tests, use test-* prefix distinct IDs that your Mixpanel data pipeline can filter out. Never send events with real user distinct IDs from test environments.
Validate event names against your Lexicon before shipping. Mixpanel Lexicon (now Data Management) tracks your approved event taxonomy. Test that every tracked event name exists in your approved Lexicon before deploying.
Mocking mixpanel-browser
// __mocks__/mixpanel-browser.ts
const mixpanelMock = {
init: jest.fn(),
track: jest.fn(),
identify: jest.fn(),
alias: jest.fn(),
reset: jest.fn(),
register: jest.fn(),
register_once: jest.fn(),
unregister: jest.fn(),
get_property: jest.fn(),
get_distinct_id: jest.fn(() => 'mock-anonymous-id'),
people: {
set: jest.fn(),
set_once: jest.fn(),
increment: jest.fn(),
append: jest.fn(),
union: jest.fn(),
unset: jest.fn(),
delete_user: jest.fn(),
},
time_event: jest.fn(),
track_links: jest.fn(),
track_forms: jest.fn(),
opt_in_tracking: jest.fn(),
opt_out_tracking: jest.fn(),
has_opted_in_tracking: jest.fn(() => true),
};
export default mixpanelMock;
beforeEach(() => {
// Reset call history AND any stored return values between tests
Object.keys(mixpanelMock).forEach(key => {
const value = (mixpanelMock as any)[key];
if (typeof value === 'function') {
(value as jest.Mock).mockReset();
} else if (typeof value === 'object') {
Object.values(value).forEach(fn => {
if (typeof fn === 'function') {
(fn as jest.Mock).mockReset();
}
});
}
});
// Restore defaults after reset
mixpanelMock.get_distinct_id.mockReturnValue('mock-anonymous-id');
mixpanelMock.has_opted_in_tracking.mockReturnValue(true);
});Analytics Service with Mixpanel
// src/analytics/mixpanel.ts
import mixpanel from 'mixpanel-browser';
export interface MixpanelConfig {
token: string;
debug?: boolean;
trackPageviews?: boolean;
}
export class MixpanelClient {
private initialized = false;
initialize(config: MixpanelConfig): void {
mixpanel.init(config.token, {
debug: config.debug ?? false,
track_pageview: config.trackPageviews ?? false,
ignore_dnt: false,
persistence: 'localStorage',
// Test environment: use different token or disable
api_host: 'https://api.mixpanel.com',
});
// Register super properties — added to every event
mixpanel.register({
app_version: process.env.REACT_APP_VERSION ?? 'unknown',
environment: process.env.NODE_ENV,
platform: 'web',
});
this.initialized = true;
}
identify(userId: string, properties: UserProperties): void {
mixpanel.identify(userId);
// Update people profile
mixpanel.people.set({
$email: properties.email,
$name: properties.name,
plan: properties.plan,
company: properties.company,
});
// Only set once — don't overwrite with repeat identify calls
mixpanel.people.set_once({
first_seen: new Date().toISOString(),
signup_source: properties.signupSource,
});
}
track(event: string, properties?: Record<string, unknown>): void {
if (!this.initialized) {
console.warn('Mixpanel not initialized');
return;
}
mixpanel.track(event, properties);
}
trackWithTiming(event: string, properties?: Record<string, unknown>): () => void {
mixpanel.time_event(event);
return () => {
mixpanel.track(event, properties); // Duration auto-included
};
}
updatePlan(newPlan: string): void {
// Use set (not set_once) for mutable properties
mixpanel.people.set({ plan: newPlan });
mixpanel.track('Plan Updated', { new_plan: newPlan });
}
incrementUsage(metric: string, amount = 1): void {
mixpanel.people.increment(metric, amount);
}
logout(): void {
mixpanel.reset();
}
}Unit Testing Events and Properties
// src/analytics/__tests__/mixpanel.test.ts
import mixpanel from 'mixpanel-browser';
import { MixpanelClient } from '../mixpanel';
describe('MixpanelClient', () => {
let client: MixpanelClient;
beforeEach(() => {
client = new MixpanelClient();
client.initialize({ token: 'test-token-123', debug: false });
});
describe('initialize', () => {
it('registers super properties on init', () => {
expect(mixpanel.register).toHaveBeenCalledWith(
expect.objectContaining({
platform: 'web',
environment: expect.any(String),
})
);
});
it('registers app_version super property', () => {
const registerCall = (mixpanel.register as jest.Mock).mock.calls[0][0];
expect(registerCall).toHaveProperty('app_version');
});
});
describe('identify', () => {
const testUser = {
email: 'alice@example.com',
name: 'Alice Smith',
plan: 'pro',
company: 'Acme Corp',
signupSource: 'organic',
};
it('calls mixpanel.identify with the userId', () => {
client.identify('user_123', testUser);
expect(mixpanel.identify).toHaveBeenCalledWith('user_123');
});
it('sets mutable people properties with people.set', () => {
client.identify('user_123', testUser);
expect(mixpanel.people.set).toHaveBeenCalledWith(
expect.objectContaining({
$email: 'alice@example.com',
$name: 'Alice Smith',
plan: 'pro',
})
);
});
it('sets immutable properties with people.set_once', () => {
client.identify('user_123', testUser);
expect(mixpanel.people.set_once).toHaveBeenCalledWith(
expect.objectContaining({
first_seen: expect.any(String),
signup_source: 'organic',
})
);
});
it('does not use people.set for signup_source (would overwrite on re-login)', () => {
client.identify('user_123', testUser);
const setCalls = (mixpanel.people.set as jest.Mock).mock.calls;
for (const [properties] of setCalls) {
expect(properties).not.toHaveProperty('signup_source',
'signup_source must use set_once to prevent overwriting on re-login');
}
});
});
describe('updatePlan', () => {
it('uses people.set (not set_once) for plan updates', () => {
client.updatePlan('enterprise');
expect(mixpanel.people.set).toHaveBeenCalledWith({ plan: 'enterprise' });
// Must NOT use set_once — plan changes should overwrite
const setOnceCalls = (mixpanel.people.set_once as jest.Mock).mock.calls;
const planSetOnce = setOnceCalls.find(([props]) => 'plan' in props);
expect(planSetOnce).toBeUndefined();
});
it('tracks Plan Updated event', () => {
client.updatePlan('enterprise');
expect(mixpanel.track).toHaveBeenCalledWith('Plan Updated', {
new_plan: 'enterprise'
});
});
});
describe('track', () => {
it('fires track with event name and properties', () => {
client.track('Button Clicked', { button_id: 'hero-cta', page: 'landing' });
expect(mixpanel.track).toHaveBeenCalledWith('Button Clicked', {
button_id: 'hero-cta',
page: 'landing',
});
});
it('does not track before initialization', () => {
const uninitClient = new MixpanelClient();
uninitClient.track('Some Event');
expect(mixpanel.track).not.toHaveBeenCalled();
});
});
describe('trackWithTiming', () => {
it('calls time_event before the operation', () => {
const finish = client.trackWithTiming('API Call');
expect(mixpanel.time_event).toHaveBeenCalledWith('API Call');
expect(mixpanel.track).not.toHaveBeenCalled(); // Not yet
});
it('calls track when finish function is invoked', () => {
const finish = client.trackWithTiming('API Call', { endpoint: '/users' });
finish();
expect(mixpanel.track).toHaveBeenCalledWith('API Call', { endpoint: '/users' });
});
});
describe('logout', () => {
it('calls mixpanel.reset to clear identity', () => {
client.logout();
expect(mixpanel.reset).toHaveBeenCalledTimes(1);
});
});
});Testing Funnel Event Sequences
// src/analytics/__tests__/funnel.test.ts
import mixpanel from 'mixpanel-browser';
import { MixpanelClient } from '../mixpanel';
describe('Funnel Event Sequences', () => {
let client: MixpanelClient;
let eventLog: Array<{ event: string; properties: Record<string, unknown> }>;
beforeEach(() => {
client = new MixpanelClient();
client.initialize({ token: 'test-token' });
eventLog = [];
(mixpanel.track as jest.Mock).mockImplementation((event, properties) => {
eventLog.push({ event, properties: properties ?? {} });
});
});
it('fires correct signup funnel sequence', () => {
client.track('Signup Started', { source: 'homepage' });
client.track('Email Entered');
client.track('Plan Selected', { plan: 'pro' });
client.track('Payment Info Entered');
client.track('Signup Completed', { plan: 'pro', trial: true });
const eventNames = eventLog.map(e => e.event);
expect(eventNames).toEqual([
'Signup Started',
'Email Entered',
'Plan Selected',
'Payment Info Entered',
'Signup Completed',
]);
});
it('Signup Completed includes the selected plan from Plan Selected', () => {
client.track('Plan Selected', { plan: 'enterprise' });
client.track('Signup Completed', { plan: 'enterprise', trial: false });
const completedEvent = eventLog.find(e => e.event === 'Signup Completed');
expect(completedEvent?.properties.plan).toBe('enterprise');
});
it('does not fire Signup Completed without Plan Selected', () => {
// This test documents expected behavior — your implementation
// may enforce this or leave it to the calling code
const eventNames = eventLog.map(e => e.event);
expect(eventNames).not.toContain('Signup Completed');
});
it('Plan Selected and Signup Completed have matching plan values', () => {
const selectedPlan = 'pro';
client.track('Plan Selected', { plan: selectedPlan });
client.track('Signup Completed', { plan: selectedPlan });
const planSelected = eventLog.find(e => e.event === 'Plan Selected');
const signupCompleted = eventLog.find(e => e.event === 'Signup Completed');
expect(planSelected?.properties.plan).toBe(signupCompleted?.properties.plan);
});
});Schema Validation Against Lexicon
// src/analytics/mixpanel-lexicon.ts
const LEXICON: Record<string, {
description: string;
required: string[];
optional: string[];
propertyTypes: Record<string, string>;
}> = {
'Signup Started': {
description: 'User begins signup flow',
required: ['source'],
optional: ['referrer', 'utm_campaign', 'utm_source'],
propertyTypes: { source: 'string', referrer: 'string' },
},
'Plan Selected': {
description: 'User selects a subscription plan',
required: ['plan'],
optional: ['billing_period', 'is_trial'],
propertyTypes: { plan: 'string', billing_period: 'string', is_trial: 'boolean' },
},
'Signup Completed': {
description: 'User successfully completes signup',
required: ['plan', 'trial'],
optional: ['coupon'],
propertyTypes: { plan: 'string', trial: 'boolean', coupon: 'string' },
},
'Purchase Completed': {
description: 'User completes a purchase',
required: ['order_id', 'revenue', 'plan'],
optional: ['coupon', 'billing_period'],
propertyTypes: {
order_id: 'string',
revenue: 'number',
plan: 'string',
coupon: 'string',
},
},
};
export function validateMixpanelEvent(
eventName: string,
properties: Record<string, unknown>
): { valid: boolean; errors: string[] } {
const schema = LEXICON[eventName];
const errors: string[] = [];
if (!schema) {
return {
valid: false,
errors: [`Event "${eventName}" not found in Lexicon — add it before tracking`]
};
}
// Check required properties
for (const required of schema.required) {
if (!(required in properties) || properties[required] === undefined || properties[required] === null) {
errors.push(`Missing required property: "${required}"`);
}
}
// Check property types
for (const [prop, value] of Object.entries(properties)) {
const expectedType = schema.propertyTypes[prop];
if (expectedType && typeof value !== expectedType) {
errors.push(
`Property "${prop}": expected ${expectedType}, got ${typeof value}`
);
}
}
return { valid: errors.length === 0, errors };
}// src/analytics/__tests__/lexicon.test.ts
import { validateMixpanelEvent } from '../mixpanel-lexicon';
describe('Mixpanel Lexicon Validation', () => {
test('validates correct Signup Completed event', () => {
const result = validateMixpanelEvent('Signup Completed', {
plan: 'pro',
trial: true,
});
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test('catches revenue as string in Purchase Completed', () => {
const result = validateMixpanelEvent('Purchase Completed', {
order_id: 'ord_123',
revenue: '99.99', // String — should be number
plan: 'pro',
});
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('revenue'))).toBe(true);
});
test('rejects events not in Lexicon', () => {
const result = validateMixpanelEvent('Unknown Random Event', {});
expect(result.valid).toBe(false);
expect(result.errors[0]).toContain('not found in Lexicon');
});
test('catches missing required properties', () => {
const result = validateMixpanelEvent('Signup Completed', {
// Missing 'plan' and 'trial'
});
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(2);
});
});Test Environment Isolation
Prevent test events from polluting production data:
// src/analytics/mixpanel-factory.ts
import mixpanel from 'mixpanel-browser';
export function createMixpanelClient(env: 'production' | 'staging' | 'test') {
const tokens = {
production: process.env.REACT_APP_MIXPANEL_TOKEN_PROD!,
staging: process.env.REACT_APP_MIXPANEL_TOKEN_STAGING!,
test: 'test_token_disabled', // Mixpanel ignores this
};
const token = tokens[env];
if (env === 'test') {
// In test environment, opt out completely
return {
initialize: () => {
mixpanel.init(token, { opt_out_tracking_by_default: true });
},
track: () => {}, // No-op
identify: () => {}, // No-op
// etc.
};
}
return new MixpanelClient();
}// For integration tests that need real Mixpanel calls (staging)
describe('Mixpanel Staging Integration', () => {
const STAGING_DISTINCT_ID = `test-automated-${Date.now()}`;
beforeAll(() => {
// Use staging token with test prefix distinct_id
mixpanel.init(process.env.MIXPANEL_STAGING_TOKEN!);
mixpanel.identify(STAGING_DISTINCT_ID);
// Register test flag for data pipeline filtering
mixpanel.register({ is_automated_test: true });
});
it('delivers Purchase Completed to staging Mixpanel', async () => {
mixpanel.track('Purchase Completed', {
order_id: `test-ord-${Date.now()}`,
revenue: 0.01, // Minimal to avoid corrupting revenue metrics
plan: 'test',
});
// Verify via Mixpanel Data Export API (staging project)
await expect(
pollForEvent('Purchase Completed', STAGING_DISTINCT_ID, { timeout: 30000 })
).resolves.toBeTruthy();
});
});CI Pipeline
# .github/workflows/mixpanel-tests.yml
name: Mixpanel Analytics Tests
on:
pull_request:
paths:
- 'src/analytics/**'
- 'src/**/*.tsx'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Run analytics tests
run: npm test -- --testPathPattern='mixpanel|analytics'
- name: Validate Lexicon coverage
run: |
# Find all mixpanel.track calls and check against Lexicon
node scripts/validate-event-lexicon.jsContinuous Monitoring with HelpMeTest
HelpMeTest verifies Mixpanel is receiving events in production on a schedule:
*** Test Cases ***
Mixpanel Event Delivery Smoke Test
[Documentation] Verify Mixpanel is receiving events from production app
${recent_count}= Get Mixpanel Event Count event=Signup Completed hours=24
Should Be True ${recent_count} > 0
... msg=No Signup Completed events in last 24h — Mixpanel tracking may be broken
Mixpanel People Properties Integrity
[Documentation] Sample recent users and verify people properties are set
${users}= Get Recent Mixpanel Users count=10
FOR ${user} IN @{users}
Property Should Exist ${user} plan
Property Should Exist ${user} $email
ENDSet up alerts when event volumes drop unexpectedly — catch tracking outages before they affect days of analytics data.
Summary
Mixpanel testing requires handling its stateful SDK correctly:
- Complete mocking — use
mockReset()(notmockClear()) between tests to reset SDK state - Super property testing — verify
register()calls because super properties affect every event setvsset_once— test that mutable properties usepeople.setand immutable ones usepeople.set_once; confusion between them corrupts cohort analysis- Funnel sequences — verify events fire in correct order and carry consistent property values across steps
- Lexicon validation — validate event names and property types against your Lexicon before shipping
- Environment isolation — use distinct tokens per environment and mark test events with
is_automated_test: truefor data pipeline filtering
The discipline: always test property values, not just that events fired. A wrong property type in a revenue event corrupts your MRR calculations.