PostHog Integration Testing: Feature Flags, Analytics Events, and Session Recording

PostHog Integration Testing: Feature Flags, Analytics Events, and Session Recording

PostHog combines product analytics, feature flags, session recording, and A/B testing in one platform. Testing PostHog integrations requires more than mocking — you need to test feature flag evaluation, verify event tracking, and ensure session recording is properly configured without capturing sensitive data. This guide covers all three layers with practical Jest and Cypress examples.

Key Takeaways

Mock posthog-js completely in unit tests. PostHog's SDK makes network calls and accesses localStorage. Mock the entire module so tests are fast and don't require internet access.

Test feature flag behavior with both true and false values. Your app has branching behavior based on flags. Test both paths — the flag-enabled and flag-disabled code paths — for every flag in your application.

Use PostHog's distinct_id as your testing handle. When testing event delivery to PostHog's API, use a predictable distinct_id pattern (like test-{timestamp}) to find and verify events via the API.

Test that session recording masks sensitive fields. If your PostHog configuration uses maskAllInputs or custom masks, write tests that verify PII (passwords, card numbers, SSNs) doesn't appear in the recording configuration.

Isolate PostHog in CI with a dedicated test project. Use a separate PostHog project (with a different API key) for CI events so test noise doesn't pollute your production analytics.

Mocking PostHog in Unit Tests

Module-Level Mock

// __mocks__/posthog-js.ts
const posthogMock = {
  init: jest.fn(),
  capture: jest.fn(),
  identify: jest.fn(),
  alias: jest.fn(),
  reset: jest.fn(),
  people: {
    set: jest.fn(),
    set_once: jest.fn(),
  },
  isFeatureEnabled: jest.fn(),
  getFeatureFlag: jest.fn(),
  onFeatureFlags: jest.fn((callback) => {
    // Immediately invoke the callback (synchronous in tests)
    callback();
    return () => {};  // Return cleanup function
  }),
  overrideFeatureFlags: jest.fn(),
  reloadFeatureFlags: jest.fn(),
  getDistinctId: jest.fn(() => 'test-user-123'),
  opt_in_capturing: jest.fn(),
  opt_out_capturing: jest.fn(),
  has_opted_in_capturing: jest.fn(() => true),
  get_property: jest.fn(),
  register: jest.fn(),
  unregister: jest.fn(),
  group: jest.fn(),
  
  // Session recording
  startSessionRecording: jest.fn(),
  stopSessionRecording: jest.fn(),
  isSessionRecordingEnabled: jest.fn(() => false),
};

export default posthogMock;

// Auto-reset between tests
beforeEach(() => {
  Object.values(posthogMock).forEach(fn => {
    if (typeof fn === 'function') {
      (fn as jest.Mock).mockClear();
    }
  });
  
  // Reset feature flag defaults
  posthogMock.isFeatureEnabled.mockReturnValue(false);
  posthogMock.getFeatureFlag.mockReturnValue(undefined);
});

Analytics Client with PostHog

// src/analytics/posthog.ts
import posthog from 'posthog-js';

export class PostHogClient {
  
  initialize(apiKey: string, host?: string): void {
    posthog.init(apiKey, {
      api_host: host ?? 'https://app.posthog.com',
      capture_pageview: false,      // Manual page tracking
      capture_pageleave: true,
      autocapture: false,            // Manual event tracking only
      session_recording: {
        maskAllInputs: true,         // Mask all input fields by default
        maskInputFn: (text, element) => {
          // Unmask non-sensitive fields
          const type = element?.getAttribute('type');
          if (type === 'password' || type === 'credit-card') {
            return '*'.repeat(text.length);
          }
          return text;
        },
      },
      loaded: (ph) => {
        if (process.env.NODE_ENV === 'test') {
          ph.opt_out_capturing();  // Don't send events in test environment
        }
      },
    });
  }
  
  identify(userId: string, properties: Record<string, unknown>): void {
    posthog.identify(userId, {
      email: properties.email,
      name: properties.name,
      plan: properties.plan,
    });
  }
  
  capture(event: string, properties?: Record<string, unknown>): void {
    posthog.capture(event, properties);
  }
  
  page(pageName: string, properties?: Record<string, unknown>): void {
    posthog.capture('$pageview', {
      $current_url: window.location.href,
      page_name: pageName,
      ...properties,
    });
  }
  
  isFeatureEnabled(flag: string): boolean {
    return posthog.isFeatureEnabled(flag) ?? false;
  }
  
  getFeatureFlag(flag: string): string | boolean | undefined {
    return posthog.getFeatureFlag(flag);
  }
}

// Singleton instance
export const analytics = new PostHogClient();

Testing Feature Flags

Feature flags add branching logic — test both branches:

// src/features/new-dashboard/__tests__/new-dashboard.test.tsx
import { render, screen } from '@testing-library/react';
import posthog from 'posthog-js';
import { Dashboard } from '../Dashboard';

describe('Dashboard Feature Flag', () => {
  describe('when new-dashboard flag is enabled', () => {
    beforeEach(() => {
      (posthog.isFeatureEnabled as jest.Mock).mockImplementation(
        (flag: string) => flag === 'new-dashboard'
      );
    });
    
    it('renders the new dashboard component', () => {
      render(<Dashboard />);
      expect(screen.getByTestId('new-dashboard-v2')).toBeInTheDocument();
      expect(screen.queryByTestId('legacy-dashboard')).not.toBeInTheDocument();
    });
    
    it('tracks new-dashboard-viewed event', () => {
      render(<Dashboard />);
      expect(posthog.capture).toHaveBeenCalledWith('new-dashboard-viewed', expect.any(Object));
    });
  });
  
  describe('when new-dashboard flag is disabled (control group)', () => {
    beforeEach(() => {
      (posthog.isFeatureEnabled as jest.Mock).mockReturnValue(false);
    });
    
    it('renders the legacy dashboard', () => {
      render(<Dashboard />);
      expect(screen.getByTestId('legacy-dashboard')).toBeInTheDocument();
      expect(screen.queryByTestId('new-dashboard-v2')).not.toBeInTheDocument();
    });
    
    it('does not track new-dashboard-viewed', () => {
      render(<Dashboard />);
      
      const newDashboardEvents = (posthog.capture as jest.Mock).mock.calls
        .filter(([event]) => event === 'new-dashboard-viewed');
      
      expect(newDashboardEvents).toHaveLength(0);
    });
  });
  
  describe('multivariate flag with variant values', () => {
    it('renders pricing-v2 component for pricing-test=variant-b', () => {
      (posthog.getFeatureFlag as jest.Mock).mockImplementation(
        (flag: string) => flag === 'pricing-test' ? 'variant-b' : undefined
      );
      
      render(<PricingPage />);
      expect(screen.getByTestId('pricing-table-v2')).toBeInTheDocument();
    });
    
    it('renders original pricing for pricing-test=control', () => {
      (posthog.getFeatureFlag as jest.Mock).mockImplementation(
        (flag: string) => flag === 'pricing-test' ? 'control' : undefined
      );
      
      render(<PricingPage />);
      expect(screen.getByTestId('pricing-table-v1')).toBeInTheDocument();
    });
  });
});

Testing Components That Wait for Flags

// Testing components that use onFeatureFlags callback
describe('Feature Flag Loading State', () => {
  it('shows loading state before flags are ready', () => {
    // Make onFeatureFlags NOT immediately invoke callback (simulating async)
    (posthog.onFeatureFlags as jest.Mock).mockImplementation(() => {
      // Don't call callback — flags are loading
      return () => {};
    });
    
    render(<FeatureFlagGate flag="new-feature" />);
    
    expect(screen.getByRole('progressbar')).toBeInTheDocument();
    expect(screen.queryByTestId('new-feature-content')).not.toBeInTheDocument();
  });
  
  it('shows content after flags load', async () => {
    let flagCallback: (() => void) | null = null;
    
    (posthog.onFeatureFlags as jest.Mock).mockImplementation((callback) => {
      flagCallback = callback;
      return () => {};
    });
    
    (posthog.isFeatureEnabled as jest.Mock).mockReturnValue(true);
    
    render(<FeatureFlagGate flag="new-feature" />);
    
    // Simulate flags loading
    flagCallback?.();
    
    await screen.findByTestId('new-feature-content');
    expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
  });
});

Testing Event Tracking

// src/analytics/__tests__/posthog-events.test.ts
import posthog from 'posthog-js';
import { PostHogClient } from '../posthog';

describe('PostHogClient event tracking', () => {
  let client: PostHogClient;
  
  beforeEach(() => {
    client = new PostHogClient();
  });
  
  describe('capture', () => {
    it('passes event name and properties to posthog.capture', () => {
      client.capture('Button Clicked', {
        button_id: 'cta-header',
        page: 'landing',
      });
      
      expect(posthog.capture).toHaveBeenCalledWith('Button Clicked', {
        button_id: 'cta-header',
        page: 'landing',
      });
    });
    
    it('does not include undefined properties', () => {
      client.capture('Page Viewed', {
        page: 'dashboard',
        referrer: undefined,  // Should not be included
      });
      
      const [, properties] = (posthog.capture as jest.Mock).mock.calls[0];
      expect(properties).not.toHaveProperty('referrer');
    });
  });
  
  describe('identify', () => {
    it('calls posthog.identify with userId and correct properties', () => {
      client.identify('user_42', {
        email: 'test@example.com',
        name: 'Test User',
        plan: 'pro',
      });
      
      expect(posthog.identify).toHaveBeenCalledWith('user_42', {
        email: 'test@example.com',
        name: 'Test User',
        plan: 'pro',
      });
    });
    
    it('never sends password or sensitive fields as user properties', () => {
      client.identify('user_42', {
        email: 'test@example.com',
        name: 'Test User',
        plan: 'pro',
        password: 'secret123',  // Should be stripped
        creditCard: '4111111111111111',  // Should be stripped
      });
      
      const [, properties] = (posthog.identify as jest.Mock).mock.calls[0];
      expect(properties).not.toHaveProperty('password');
      expect(properties).not.toHaveProperty('creditCard');
    });
  });
  
  describe('page tracking', () => {
    it('fires $pageview with page_name and current URL', () => {
      Object.defineProperty(window, 'location', {
        value: { href: 'https://app.example.com/dashboard' },
        writable: true,
      });
      
      client.page('Dashboard');
      
      expect(posthog.capture).toHaveBeenCalledWith('$pageview', {
        $current_url: 'https://app.example.com/dashboard',
        page_name: 'Dashboard',
      });
    });
  });
});

Testing Session Recording Configuration

// src/analytics/__tests__/session-recording.test.ts
import { PostHogClient } from '../posthog';
import posthog from 'posthog-js';

describe('Session Recording Configuration', () => {
  it('initializes with maskAllInputs enabled', () => {
    const client = new PostHogClient();
    client.initialize('test-key');
    
    const initCall = (posthog.init as jest.Mock).mock.calls[0];
    const config = initCall[1];
    
    expect(config.session_recording.maskAllInputs).toBe(true);
  });
  
  it('masks password fields in session recording', () => {
    const client = new PostHogClient();
    client.initialize('test-key');
    
    const initCall = (posthog.init as jest.Mock).mock.calls[0];
    const maskFn = initCall[1].session_recording?.maskInputFn;
    
    if (!maskFn) {
      fail('maskInputFn must be configured');
    }
    
    // Test password masking
    const passwordElement = document.createElement('input');
    passwordElement.setAttribute('type', 'password');
    
    const masked = maskFn('mysecretpassword', passwordElement);
    expect(masked).toBe('*'.repeat('mysecretpassword'.length));
    expect(masked).not.toContain('mysecretpassword');
  });
  
  it('does not mask non-sensitive search fields', () => {
    const client = new PostHogClient();
    client.initialize('test-key');
    
    const initCall = (posthog.init as jest.Mock).mock.calls[0];
    const maskFn = initCall[1].session_recording?.maskInputFn;
    
    const searchElement = document.createElement('input');
    searchElement.setAttribute('type', 'search');
    
    const result = maskFn('search query', searchElement);
    expect(result).toBe('search query');  // Not masked
  });
  
  it('disables capturing in test environment', () => {
    const originalEnv = process.env.NODE_ENV;
    process.env.NODE_ENV = 'test';
    
    const client = new PostHogClient();
    client.initialize('test-key');
    
    const initCall = (posthog.init as jest.Mock).mock.calls[0];
    const loadedFn = initCall[1].loaded;
    
    // Call loaded callback with mock posthog instance
    const mockPH = { opt_out_capturing: jest.fn() };
    loadedFn(mockPH);
    
    expect(mockPH.opt_out_capturing).toHaveBeenCalled();
    
    process.env.NODE_ENV = originalEnv;
  });
});

End-to-End Testing with Cypress

// cypress/e2e/analytics/posthog.cy.ts

describe('PostHog Event Tracking', () => {
  beforeEach(() => {
    // Intercept PostHog API calls
    cy.intercept('POST', 'https://app.posthog.com/capture/', (req) => {
      // Record the event for assertion
      req.reply({ status: 1 });
    }).as('posthogCapture');
  });
  
  it('fires page view event on navigation', () => {
    cy.visit('/dashboard');
    
    cy.wait('@posthogCapture').then((interception) => {
      const body = interception.request.body;
      const events = typeof body === 'string' ? JSON.parse(body) : body;
      
      const pageviewEvent = (Array.isArray(events.batch) ? events.batch : [events])
        .find((e: any) => e.event === '$pageview');
      
      expect(pageviewEvent).to.exist;
      expect(pageviewEvent.properties.page_name).to.equal('Dashboard');
    });
  });
  
  it('captures button click event with correct properties', () => {
    cy.visit('/pricing');
    
    cy.intercept('POST', 'https://app.posthog.com/capture/').as('posthogCapture2');
    cy.get('[data-testid="upgrade-cta"]').click();
    
    cy.wait('@posthogCapture2').then((interception) => {
      const events = JSON.parse(interception.request.body);
      const clickEvent = events.batch?.find((e: any) => e.event === 'Upgrade CTA Clicked');
      
      expect(clickEvent).to.exist;
      expect(clickEvent.properties).to.have.property('plan_selected');
      expect(clickEvent.properties).to.have.property('page', 'pricing');
    });
  });
  
  it('feature flag is evaluated before rendering conditional content', () => {
    // Override feature flag via PostHog's test utilities
    cy.window().then((win) => {
      win.posthog?.overrideFeatureFlags({ 'new-checkout': true });
    });
    
    cy.visit('/checkout');
    cy.get('[data-testid="new-checkout-flow"]').should('exist');
  });
});

Integration Testing with PostHog Test Environment

# tests/integration/test_posthog_delivery.py
import pytest
import time
import requests

POSTHOG_HOST = "https://app.posthog.com"
POSTHOG_TEST_API_KEY = "phx_test_key"  # Staging project key
POSTHOG_PERSONAL_API_KEY = "your_personal_api_key"
POSTHOG_PROJECT_ID = "your_project_id"

def test_event_delivered_to_posthog():
    """Verify events actually reach PostHog's API."""
    distinct_id = f"test-{int(time.time())}"
    
    # Send event directly via PostHog capture API
    response = requests.post(
        f"{POSTHOG_HOST}/capture/",
        json={
            "api_key": POSTHOG_TEST_API_KEY,
            "event": "Integration Test Event",
            "distinct_id": distinct_id,
            "properties": {
                "test_run": True,
                "timestamp": time.time()
            }
        }
    )
    assert response.status_code == 200
    
    # Wait for event to be processed
    time.sleep(5)
    
    # Verify event in PostHog via API
    query_response = requests.post(
        f"{POSTHOG_HOST}/api/projects/{POSTHOG_PROJECT_ID}/query/",
        json={
            "query": {
                "kind": "EventsQuery",
                "select": ["event", "distinct_id", "timestamp"],
                "where": [f"distinct_id = '{distinct_id}'"],
                "limit": 10,
            }
        },
        headers={"Authorization": f"Bearer {POSTHOG_PERSONAL_API_KEY}"}
    )
    
    data = query_response.json()
    results = data.get("results", [])
    
    event_found = any(
        row[0] == "Integration Test Event" and row[1] == distinct_id
        for row in results
    )
    assert event_found, f"Event not found in PostHog for distinct_id={distinct_id}"

CI Integration

# .github/workflows/posthog-tests.yml
name: PostHog Analytics Tests

on:
  pull_request:
    paths:
      - 'src/analytics/**'
      - 'src/features/**'

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test -- --testPathPattern='posthog|feature-flag'
      
  validate-feature-flags:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check all feature flags have test coverage
        run: node scripts/check-feature-flag-coverage.js

Monitoring PostHog Integration with HelpMeTest

HelpMeTest can run scheduled tests that verify PostHog is capturing events correctly in production:

*** Test Cases ***
PostHog Feature Flag Smoke Test
    [Documentation]    Verify feature flags are evaluated correctly for test user
    ${flag_value}=    Evaluate Feature Flag    new-dashboard    test-user-123
    Should Be True    ${flag_value} in [True, False]    
    ...    msg=Feature flag evaluation failed — PostHog may be unreachable
    
    ${events}=    Count PostHog Events Last Hour    $pageview
    Should Be True    ${events} > 0    msg=No page view events in last hour — tracking may be broken

Set up daily checks against your production PostHog project to detect when event ingestion drops unexpectedly.

Summary

PostHog integration testing covers three layers:

  1. Unit tests — mock posthog-js completely; test feature flag behavior for both enabled and disabled states; validate event schemas
  2. Component tests — test conditional rendering based on feature flags; test loading states while flags load asynchronously
  3. Session recording tests — verify maskAllInputs, custom mask functions, and that sensitive data doesn't appear in recording configuration
  4. Integration tests — use PostHog's capture API with a test project key to verify end-to-end event delivery

The critical pattern for feature flags: always test both the enabled AND disabled code path. Missing the control group test means half your A/B test surface is untested.

Read more