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"># ReactThe Testing Challenge
Statsig's SDK by default makes network calls to Statsig's evaluation servers. In tests, you want:
- No network calls
- Deterministic results
- 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 thisFor 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, useoverrideGate/overrideConfigper 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.