Amplitude Event Tracking Testing: SDK Mocks, Event Validation, and Funnel Integrity

Amplitude Event Tracking Testing: SDK Mocks, Event Validation, and Funnel Integrity

Amplitude tracking bugs are silent — the app works, but your growth metrics are wrong. Funnel drop-offs appear where users actually convert. Revenue numbers are off. This guide covers how to unit test Amplitude SDK tracking with Jest mocks, validate event schemas against your taxonomy, test funnel event sequences, and verify user property updates.

Key Takeaways

Mock the Amplitude SDK module, not window globals. Amplitude's modern SDK (@amplitude/analytics-browser) is a proper ES module. Mock it with jest.mock('@amplitude/analytics-browser') — don't spy on window globals.

Test event sequences for funnels, not just individual events. A funnel requires events to fire in the correct order. Test that Product Viewed → Add to Cart → Purchase fires in sequence without missing steps.

User properties are as important as events. If amplitude.setUserProperties() fires with the wrong plan tier, your cohort analysis is wrong. Test identify calls with the same rigor as track calls.

Test that anonymous and identified users are handled separately. Events fired before login must be linked to the user ID after login (aliasing). Test the pre-login → login → post-login tracking continuity.

Validate event properties against your taxonomy before shipping. Amplitude Event Taxonomy violations (wrong property types, missing required properties) corrupt your charts silently. Write schema tests.

Mocking Amplitude's Modern SDK

// __mocks__/@amplitude/analytics-browser.ts
export const track = jest.fn();
export const identify = jest.fn();
export const setUserId = jest.fn();
export const setGroup = jest.fn();
export const groupIdentify = jest.fn();
export const reset = jest.fn();
export const init = jest.fn();
export const Identify = jest.fn().mockImplementation(() => ({
  set: jest.fn().mockReturnThis(),
  setOnce: jest.fn().mockReturnThis(),
  add: jest.fn().mockReturnThis(),
  unset: jest.fn().mockReturnThis(),
  append: jest.fn().mockReturnThis(),
  prepend: jest.fn().mockReturnThis(),
}));

// Auto-clear between tests
beforeEach(() => {
  track.mockClear();
  identify.mockClear();
  setUserId.mockClear();
  setGroup.mockClear();
  reset.mockClear();
});

Analytics Service Layer

Centralize all Amplitude calls to make testing easier:

// src/analytics/amplitude.ts
import * as amplitude from '@amplitude/analytics-browser';

export class AmplitudeClient {
  
  initialize(apiKey: string): void {
    amplitude.init(apiKey, {
      defaultTracking: false,  // Disable auto-tracking; control manually
      logLevel: amplitude.Types.LogLevel.Warn,
    });
  }
  
  // User identification
  identifyUser(userId: string, properties: UserProperties): void {
    amplitude.setUserId(userId);
    
    const identifyEvent = new amplitude.Identify();
    identifyEvent.set('email', properties.email);
    identifyEvent.set('plan', properties.plan);
    identifyEvent.set('account_created_at', properties.createdAt);
    identifyEvent.setOnce('first_seen_at', new Date().toISOString());
    
    amplitude.identify(identifyEvent);
  }
  
  // Track page views
  trackPageView(pageName: string, properties?: Record<string, unknown>): void {
    amplitude.track('Page Viewed', {
      page_name: pageName,
      url: window.location.href,
      referrer: document.referrer,
      ...properties,
    });
  }
  
  // Ecommerce events
  trackProductViewed(product: ProductProperties): void {
    amplitude.track('Product Viewed', {
      product_id: product.id,
      product_name: product.name,
      category: product.category,
      price: product.price,
      currency: product.currency ?? 'USD',
    });
  }
  
  trackAddToCart(product: ProductProperties, quantity: number): void {
    amplitude.track('Product Added to Cart', {
      product_id: product.id,
      product_name: product.name,
      price: product.price,
      quantity,
      cart_id: this.getCartId(),
    });
  }
  
  trackCheckoutStarted(cartValue: number, items: CartItem[]): void {
    amplitude.track('Checkout Started', {
      cart_value: cartValue,
      item_count: items.length,
      product_ids: items.map(i => i.productId),
    });
  }
  
  trackPurchaseCompleted(order: OrderProperties): void {
    amplitude.track('Purchase Completed', {
      order_id: order.id,
      revenue: order.total,
      currency: order.currency,
      quantity: order.items.reduce((sum, i) => sum + i.quantity, 0),
      products: order.items.map(item => ({
        product_id: item.productId,
        product_name: item.name,
        price: item.price,
        quantity: item.quantity,
      })),
    });
  }
  
  private getCartId(): string {
    let cartId = sessionStorage.getItem('cart_id');
    if (!cartId) {
      cartId = crypto.randomUUID();
      sessionStorage.setItem('cart_id', cartId);
    }
    return cartId;
  }
}

Unit Testing Events

// src/analytics/__tests__/amplitude.test.ts
import * as amplitude from '@amplitude/analytics-browser';
import { AmplitudeClient } from '../amplitude';

describe('AmplitudeClient', () => {
  let client: AmplitudeClient;
  
  beforeEach(() => {
    client = new AmplitudeClient();
  });
  
  describe('identifyUser', () => {
    it('sets userId and calls identify with correct properties', () => {
      client.identifyUser('user_123', {
        email: 'alice@example.com',
        plan: 'pro',
        createdAt: '2026-01-01T00:00:00Z',
      });
      
      expect(amplitude.setUserId).toHaveBeenCalledWith('user_123');
      expect(amplitude.identify).toHaveBeenCalledTimes(1);
      
      // Verify Identify object was configured correctly
      const identifyInstance = (amplitude.Identify as jest.Mock).mock.instances[0];
      expect(identifyInstance.set).toHaveBeenCalledWith('email', 'alice@example.com');
      expect(identifyInstance.set).toHaveBeenCalledWith('plan', 'pro');
      expect(identifyInstance.setOnce).toHaveBeenCalledWith(
        'first_seen_at',
        expect.any(String)
      );
    });
    
    it('uses setOnce for first_seen_at to prevent overwriting', () => {
      client.identifyUser('user_123', {
        email: 'alice@example.com',
        plan: 'pro',
        createdAt: '2026-01-01T00:00:00Z',
      });
      
      const identifyInstance = (amplitude.Identify as jest.Mock).mock.instances[0];
      
      // Must use setOnce, not set
      const setOnceCalls = identifyInstance.setOnce.mock.calls;
      const firstSeenCall = setOnceCalls.find((call: string[]) => call[0] === 'first_seen_at');
      expect(firstSeenCall).toBeDefined();
      
      // Must NOT use set for first_seen_at (would overwrite)
      const setCalls = identifyInstance.set.mock.calls;
      const firstSeenSetCall = setCalls.find((call: string[]) => call[0] === 'first_seen_at');
      expect(firstSeenSetCall).toBeUndefined();
    });
  });
  
  describe('trackProductViewed', () => {
    it('includes all required product properties', () => {
      client.trackProductViewed({
        id: 'prod_1',
        name: 'Pro Plan Monthly',
        category: 'subscription',
        price: 100,
        currency: 'USD',
      });
      
      expect(amplitude.track).toHaveBeenCalledWith('Product Viewed', {
        product_id: 'prod_1',
        product_name: 'Pro Plan Monthly',
        category: 'subscription',
        price: 100,
        currency: 'USD',
      });
    });
    
    it('defaults currency to USD when not provided', () => {
      client.trackProductViewed({
        id: 'prod_1',
        name: 'Test Product',
        category: 'subscription',
        price: 49.99,
      });
      
      const [, properties] = (amplitude.track as jest.Mock).mock.calls[0];
      expect(properties.currency).toBe('USD');
    });
  });
  
  describe('trackPurchaseCompleted', () => {
    it('includes revenue as a number, not a string', () => {
      client.trackPurchaseCompleted({
        id: 'ord_123',
        total: 149.99,
        currency: 'USD',
        items: [{ productId: 'p1', name: 'Pro', price: 149.99, quantity: 1 }],
      });
      
      const [, properties] = (amplitude.track as jest.Mock).mock.calls[0];
      expect(typeof properties.revenue).toBe('number');
      expect(properties.revenue).toBe(149.99);
    });
    
    it('sums quantities correctly', () => {
      client.trackPurchaseCompleted({
        id: 'ord_456',
        total: 299.98,
        currency: 'USD',
        items: [
          { productId: 'p1', name: 'Pro', price: 149.99, quantity: 2 },
        ],
      });
      
      const [, properties] = (amplitude.track as jest.Mock).mock.calls[0];
      expect(properties.quantity).toBe(2);
    });
  });
});

Funnel Integrity Testing

Test that funnel events fire in the correct sequence:

// src/analytics/__tests__/funnel.test.ts
import * as amplitude from '@amplitude/analytics-browser';
import { AmplitudeClient } from '../amplitude';
import { PurchaseFlow } from '../../flows/purchase';

describe('Purchase Funnel Event Sequence', () => {
  let client: AmplitudeClient;
  let trackCalls: Array<{ event: string; properties: Record<string, unknown> }>;
  
  beforeEach(() => {
    client = new AmplitudeClient();
    trackCalls = [];
    
    // Capture all track calls in order
    (amplitude.track as jest.Mock).mockImplementation((event, properties) => {
      trackCalls.push({ event, properties });
    });
  });
  
  it('fires funnel events in correct order during purchase', async () => {
    const flow = new PurchaseFlow(client);
    
    await flow.viewProduct({ id: 'prod_1', name: 'Pro Plan', price: 100, currency: 'USD', category: 'subscription' });
    await flow.addToCart({ id: 'prod_1', name: 'Pro Plan', price: 100, currency: 'USD', category: 'subscription' }, 1);
    await flow.startCheckout(100, [{ productId: 'prod_1', quantity: 1 }]);
    await flow.completePurchase({ id: 'ord_1', total: 100, currency: 'USD', items: [{ productId: 'prod_1', name: 'Pro Plan', price: 100, quantity: 1 }] });
    
    const eventNames = trackCalls.map(c => c.event);
    
    expect(eventNames).toEqual([
      'Product Viewed',
      'Product Added to Cart',
      'Checkout Started',
      'Purchase Completed',
    ]);
  });
  
  it('does not fire Purchase Completed without preceding Checkout Started', () => {
    // Simulate a bug where checkout started wasn't tracked
    client.trackPurchaseCompleted({
      id: 'ord_1',
      total: 100,
      currency: 'USD',
      items: [{ productId: 'prod_1', name: 'Pro', price: 100, quantity: 1 }],
    });
    
    const eventNames = trackCalls.map(c => c.event);
    expect(eventNames).not.toContain('Checkout Started');
    
    // If Checkout Started is missing, alert in test
    // (This test documents the expected behavior — if your app fires
    // Checkout Started automatically before Purchase Completed, this test should pass)
    // Adjust the assertion to match your implementation
  });
  
  it('cart_id is consistent across cart events', async () => {
    const flow = new PurchaseFlow(client);
    
    await flow.addToCart({ id: 'prod_1', name: 'Pro', price: 100, currency: 'USD', category: 'subscription' }, 1);
    await flow.startCheckout(100, [{ productId: 'prod_1', quantity: 1 }]);
    
    const addToCartEvent = trackCalls.find(c => c.event === 'Product Added to Cart');
    const checkoutEvent = trackCalls.find(c => c.event === 'Checkout Started');
    
    expect(addToCartEvent?.properties.cart_id).toBeDefined();
    // cart_id should be consistent within same session
    // (Checkout Started may not include cart_id — check your Taxonomy)
  });
});

Schema Validation with Amplitude's Taxonomy

// src/analytics/amplitude-taxonomy.ts
import Ajv from 'ajv';

const ajv = new Ajv({ allErrors: true });

export const AMPLITUDE_TAXONOMY: Record<string, object> = {
  'Page Viewed': {
    type: 'object',
    required: ['page_name', 'url'],
    properties: {
      page_name: { type: 'string', minLength: 1 },
      url: { type: 'string', format: 'uri' },
      referrer: { type: 'string' },
    },
  },
  
  'Product Viewed': {
    type: 'object',
    required: ['product_id', 'product_name', 'price', 'currency'],
    properties: {
      product_id: { type: 'string' },
      product_name: { type: 'string' },
      category: { type: 'string' },
      price: { type: 'number', minimum: 0 },
      currency: { type: 'string', pattern: '^[A-Z]{3}$' },
    },
  },
  
  'Product Added to Cart': {
    type: 'object',
    required: ['product_id', 'product_name', 'price', 'quantity', 'cart_id'],
    properties: {
      product_id: { type: 'string' },
      product_name: { type: 'string' },
      price: { type: 'number', minimum: 0 },
      quantity: { type: 'integer', minimum: 1 },
      cart_id: { type: 'string' },
    },
  },
  
  'Purchase Completed': {
    type: 'object',
    required: ['order_id', 'revenue', 'currency', 'quantity', 'products'],
    properties: {
      order_id: { type: 'string' },
      revenue: { type: 'number', minimum: 0 },
      currency: { type: 'string', pattern: '^[A-Z]{3}$' },
      quantity: { type: 'integer', minimum: 1 },
      products: {
        type: 'array',
        minItems: 1,
        items: {
          type: 'object',
          required: ['product_id', 'price', 'quantity'],
          properties: {
            product_id: { type: 'string' },
            product_name: { type: 'string' },
            price: { type: 'number', minimum: 0 },
            quantity: { type: 'integer', minimum: 1 },
          },
        },
      },
    },
  },
};

export function validateAmplitudeEvent(
  eventName: string,
  properties: Record<string, unknown>
): { valid: boolean; errors: string[] } {
  const schema = AMPLITUDE_TAXONOMY[eventName];
  if (!schema) {
    return { valid: false, errors: [`Unknown event: "${eventName}"`] };
  }
  
  const validate = ajv.compile(schema);
  const valid = validate(properties);
  
  return {
    valid: !!valid,
    errors: validate.errors?.map(e => `${e.instancePath} ${e.message}`) ?? [],
  };
}
// src/analytics/__tests__/amplitude-taxonomy.test.ts
import { validateAmplitudeEvent } from '../amplitude-taxonomy';

describe('Amplitude Taxonomy Validation', () => {
  describe('Purchase Completed', () => {
    it('validates a correct purchase event', () => {
      const result = validateAmplitudeEvent('Purchase Completed', {
        order_id: 'ord_123',
        revenue: 149.99,
        currency: 'USD',
        quantity: 2,
        products: [
          { product_id: 'p1', product_name: 'Pro Plan', price: 149.99, quantity: 2 }
        ]
      });
      
      expect(result.valid).toBe(true);
      expect(result.errors).toHaveLength(0);
    });
    
    it('catches revenue as string (common bug from form values)', () => {
      const result = validateAmplitudeEvent('Purchase Completed', {
        order_id: 'ord_123',
        revenue: '149.99',  // String instead of number
        currency: 'USD',
        quantity: 1,
        products: [{ product_id: 'p1', price: 149.99, quantity: 1 }]
      });
      
      expect(result.valid).toBe(false);
      expect(result.errors.some(e => e.includes('revenue'))).toBe(true);
    });
  });
});

Testing User Property Updates

describe('User Property Management', () => {
  it('updates plan property on upgrade', () => {
    client.identifyUser('user_123', { email: 'alice@example.com', plan: 'free', createdAt: '2026-01-01' });
    
    // Simulate plan upgrade
    client.updateUserPlan('pro');
    
    const latestIdentifyInstance = (amplitude.Identify as jest.Mock).mock.instances.at(-1);
    expect(latestIdentifyInstance.set).toHaveBeenCalledWith('plan', 'pro');
  });
  
  it('calls reset on logout to prevent identity bleed', () => {
    client.logout();
    
    expect(amplitude.reset).toHaveBeenCalledTimes(1);
    expect(amplitude.setUserId).toHaveBeenCalledWith(undefined);
  });
});

Continuous Monitoring with HelpMeTest

Run automated purchase flow tests that verify analytics fire correctly in production with HelpMeTest:

*** Test Cases ***
Purchase Flow Analytics Smoke Test
    [Documentation]    Verify Amplitude events fire during checkout flow
    Open Browser    https://app.example.com/products
    Click Product    Pro Plan Monthly
    Verify Analytics Event    Product Viewed    product_id    product_name
    
    Click Button    Add to Cart
    Verify Analytics Event    Product Added to Cart    product_id    quantity    cart_id
    
    Click Button    Checkout
    Verify Analytics Event    Checkout Started    cart_value    item_count

Run this daily against your staging environment — catch analytics regressions before they corrupt production data.

Summary

Amplitude analytics testing requires:

  1. Mock the SDK modulejest.mock('@amplitude/analytics-browser') for clean, isolated tests
  2. Test event schemas — validate event names, required properties, and property types against your Taxonomy
  3. Funnel sequence testing — verify events fire in the correct order without missing steps
  4. User property testing — verify Identify() calls use set vs setOnce correctly
  5. Logout/reset testing — verify amplitude.reset() fires on logout to prevent identity bleed

The critical discipline: assert on property values, not just that events fired. A wrong property type silently corrupts months of cohort analysis.

Read more