Statsig Feature Gates and Experiments: Testing Patterns for JavaScript

Statsig Feature Gates and Experiments: Testing Patterns for JavaScript

Statsig provides feature gates, dynamic configs, and A/B experiments. Its JavaScript SDK can be mocked or configured with local overrides for testing, though the approach differs slightly from GrowthBook due to Statsig's SDK design.

SDK Setup

npm install statsig-node        # Node.js server
npm install statsig-js          <span class="hljs-comment"># Browser
npm install statsig-react       <span class="hljs-comment"># React

The Testing Challenge

Statsig's SDK by default makes network calls to Statsig's evaluation servers. In tests, you want:

  1. No network calls
  2. Deterministic results
  3. Ability to test different gate/experiment states

Two approaches: mock the module or use local overrides.

Approach 1: Mock the Statsig Module

// src/features.js
import Statsig from 'statsig-node'

export async function isNewCheckoutEnabled(userId) {
  return Statsig.checkGate({ userID: userId }, 'new_checkout_flow')
}

export async function getButtonVariant(userId) {
  const exp = Statsig.getExperiment({ userID: userId }, 'button_color')
  return exp.get('color', 'blue')
}
// src/features.test.js
import { vi, test, expect, beforeEach } from 'vitest'

vi.mock('statsig-node', () => ({
  default: {
    initialize: vi.fn().mockResolvedValue(undefined),
    checkGate: vi.fn(),
    getExperiment: vi.fn(),
  }
}))

import Statsig from 'statsig-node'
import { isNewCheckoutEnabled, getButtonVariant } from './features'

beforeEach(() => {
  vi.clearAllMocks()
})

test('new checkout enabled when gate is true', async () => {
  Statsig.checkGate.mockResolvedValue(true)
  
  const enabled = await isNewCheckoutEnabled('user-123')
  expect(enabled).toBe(true)
  expect(Statsig.checkGate).toHaveBeenCalledWith(
    { userID: 'user-123' },
    'new_checkout_flow'
  )
})

test('returns green when treatment variant', async () => {
  const mockExp = { get: vi.fn().mockReturnValue('green') }
  Statsig.getExperiment.mockResolvedValue(mockExp)
  
  const color = await getButtonVariant('user-123')
  expect(color).toBe('green')
})

Approach 2: Local Overrides

Statsig Node SDK supports overrides that bypass server evaluation:

import Statsig from 'statsig-node'

// Override a gate for a specific user
Statsig.overrideGate('new_checkout_flow', true, 'user-123')

// Override for all users
Statsig.overrideGate('new_checkout_flow', true)

// Override experiment
Statsig.overrideConfig('button_color', { color: 'green' }, 'user-123')

Integration Test Setup

import Statsig from 'statsig-node'

beforeAll(async () => {
  // Initialize with local mode (no network)
  await Statsig.initialize('secret-key', {
    localMode: true,  // No server communication
  })
})

afterAll(async () => {
  await Statsig.shutdown()
})

describe('Checkout with new flow enabled', () => {
  beforeEach(() => {
    Statsig.overrideGate('new_checkout_flow', true)
  })

  afterEach(() => {
    Statsig.removeGateOverride('new_checkout_flow')
  })

  test('shows new checkout UI', async () => {
    const response = await request(app)
      .get('/checkout')
      .set('x-user-id', 'user-123')
    
    expect(response.body.checkoutVersion).toBe('v2')
  })
})

describe('Checkout with new flow disabled', () => {
  beforeEach(() => {
    Statsig.overrideGate('new_checkout_flow', false)
  })

  test('shows original checkout UI', async () => {
    const response = await request(app)
      .get('/checkout')
      .set('x-user-id', 'user-123')
    
    expect(response.body.checkoutVersion).toBe('v1')
  })
})

React Component Testing

// App.jsx
import { useGate, useExperiment } from 'statsig-react'

export function Navbar() {
  const { value: newNav } = useGate('new_navigation')
  const experiment = useExperiment('nav_cta_text')
  const ctaText = experiment.get('text', 'Sign Up')
  
  if (newNav) {
    return <NavV2 ctaText={ctaText} />
  }
  return <NavV1 ctaText={ctaText} />
}
// Navbar.test.jsx
import { render, screen } from '@testing-library/react'
import { StatsigProvider } from 'statsig-react'
import { Navbar } from './Navbar'

// Create a mock Statsig client for React tests
const mockStatsigClient = {
  checkGate: vi.fn(),
  getExperiment: vi.fn().mockReturnValue({
    get: vi.fn().mockReturnValue('Get Started'),
    logExposure: vi.fn(),
  }),
  // ... other required methods
}

test('renders V2 navigation when gate is on', () => {
  mockStatsigClient.checkGate.mockReturnValue(true)
  
  render(
    <StatsigProvider sdkKey="client-key" user={{ userID: 'test' }} waitForInitialization={false}>
      <Navbar />
    </StatsigProvider>
  )
  
  expect(screen.getByTestId('nav-v2')).toBeInTheDocument()
})

Testing Dynamic Configs

Dynamic configs let you push configuration values without code deploys. Test that your application correctly uses these values.

// config.js
export function getPricingConfig() {
  const config = Statsig.getConfig({ userID: 'system' }, 'pricing_config')
  return {
    starterPrice: config.get('starter_price', 29),
    proPrice: config.get('pro_price', 79),
    currency: config.get('currency', 'USD'),
  }
}

// config.test.js
test('uses custom pricing from dynamic config', () => {
  Statsig.overrideConfig('pricing_config', {
    starter_price: 19,
    pro_price: 59,
    currency: 'EUR',
  })
  
  const pricing = getPricingConfig()
  
  expect(pricing.starterPrice).toBe(19)
  expect(pricing.proPrice).toBe(59)
  expect(pricing.currency).toBe('EUR')
})

test('falls back to defaults when config not set', () => {
  Statsig.removeConfigOverride('pricing_config')
  
  const pricing = getPricingConfig()
  
  expect(pricing.starterPrice).toBe(29)
  expect(pricing.proPrice).toBe(79)
})

Testing Exposure Logging

test('logs experiment exposure when feature is rendered', () => {
  const logMock = vi.fn()
  
  // Intercept Statsig log calls
  vi.spyOn(Statsig, 'logEvent').mockImplementation(logMock)
  
  renderComponent()
  
  // Verify exposure was logged (Statsig does this internally,
  // but you may have custom tracking on top)
  expect(logMock).toHaveBeenCalledWith(
    expect.any(Object),
    'experiment_exposure',
    expect.objectContaining({ experiment: 'button_color' })
  )
})

CI Configuration

# .github/workflows/test.yml
env:
  STATSIG_SECRET_KEY: ${{ secrets.STATSIG_SECRET_KEY }}
  # Or use a test/staging key
  STATSIG_LOCAL_MODE: true  # If your app reads this

For fully offline CI, use the mock approach and avoid localMode initialization entirely — faster and no key needed.

Summary

Statsig tests fall into two patterns:

  • Unit tests: mock the Statsig module, return controlled values per test case
  • Integration tests: initialize with localMode: true, use overrideGate/overrideConfig per test

The mock approach is simpler and covers most cases. Use localMode only when you need to test the full middleware/handler stack with a real (but local) Statsig instance.

Read more