Get started
Back to blog

Testing code behind feature flags: strategies that work

If you have a feature behind a flag, you have two code paths. If you don’t test both, the flag will eventually flip to the one nobody tested, and it will break. Testing both paths is the whole point.

This post is the practical version of how to do that.

Rule 1: test both branches of every flag

For every flag in your code, you should have at least two tests: one where the flag is true, one where it is false.

describe('checkout page', () => {
  it('renders new checkout when flag is on', () => {
    mockFlagify({ 'new-checkout-flow': true })
    const { getByText } = render(<Checkout />)
    expect(getByText('New checkout')).toBeInTheDocument()
  })

  it('renders legacy checkout when flag is off', () => {
    mockFlagify({ 'new-checkout-flow': false })
    const { getByText } = render(<Checkout />)
    expect(getByText('Legacy checkout')).toBeInTheDocument()
  })
})

If the flag has variants, test each variant.

Rule 2: mock the flag client, not the feature

Do not mock individual isEnabled() calls. Mock the client so every call returns the configured state.

// test/helpers/mockFlagify.ts
export function mockFlagify(flagStates: Record<string, boolean | string>) {
  jest.mock('@flagify/node', () => ({
    Flagify: jest.fn().mockImplementation(() => ({
      ready: jest.fn().mockResolvedValue(undefined),
      isEnabled: jest.fn((key: string) => flagStates[key] ?? false),
      getVariant: jest.fn((key: string, fallback: string) =>
        flagStates[key] ?? fallback
      ),
      getValue: jest.fn((key: string, fallback: any) =>
        flagStates[key] ?? fallback
      ),
      destroy: jest.fn(),
    })),
  }))
}

Now every test controls flag state from one place. No scattered jest.spyOn(flagify, 'isEnabled') calls.

Rule 3: make the flag state explicit in the test

Don’t rely on global defaults. Every test that depends on a flag should set that flag explicitly, even if the value matches the default.

// bad — depends on global default
it('shows the banner', () => {
  const { getByText } = render(<Banner />)
  expect(getByText('Promo banner')).toBeInTheDocument()
})

// good — state is explicit
it('shows the banner when promo flag is on', () => {
  mockFlagify({ 'promo-banner': true })
  const { getByText } = render(<Banner />)
  expect(getByText('Promo banner')).toBeInTheDocument()
})

If the global default changes later, your test still makes sense.

Rule 4: don’t test implementation details of the flag

The goal is to test behavior, not whether isEnabled() was called. Avoid:

// bad
it('calls flagify.isEnabled with the right key', () => {
  expect(flagify.isEnabled).toHaveBeenCalledWith('new-checkout-flow')
})

This tells you nothing about whether the feature works. Test the outcome instead.

Integration tests: use a real flag state

For end-to-end or integration tests, spin up a real Flagify client pointing at a test project. Set the flags to known states before the test suite runs. Tear down after.

// test/setup.ts
import { flagify } from '@/lib/flagify'

beforeAll(async () => {
  await flagify.ready()
  // Assumes test environment has these flags preconfigured
})

We keep a dedicated test environment in Flagify for this. Each CI run sets the flags to a known state at the start, runs the tests, and doesn’t care about cleanup (the next run overwrites).

Testing per-user evaluation

For flags with targeting rules, you need to test with the right user shape. Use fixtures.

const adminUser = { id: 'u1', role: 'admin', plan: 'pro' }
const regularUser = { id: 'u2', role: 'user', plan: 'free' }

it('shows admin panel for admins', async () => {
  const result = await flagify.evaluate('admin-panel', adminUser)
  expect(result.value).toBe(true)
})

it('hides admin panel for regular users', async () => {
  const result = await flagify.evaluate('admin-panel', regularUser)
  expect(result.value).toBe(false)
})

For unit tests, mock the evaluation result. For integration tests, use a real flag with real targeting rules against a test project.

What about React component tests?

The @flagify/react SDK has a testing utility that lets you render components with mock flag values without setting up a real Provider.

import { render } from '@testing-library/react'
import { FlagifyTestProvider } from '@flagify/react/testing'

it('renders new navbar when flag is on', () => {
  const { getByText } = render(
    <FlagifyTestProvider flags={{ 'new-navbar': true }}>
      <Navbar />
    </FlagifyTestProvider>
  )
  expect(getByText('New nav')).toBeInTheDocument()
})

No mocking the whole SDK, no singleton to worry about.

Don’t forget the stale paths

Once a flag has been at 100% for weeks, the “false” path is dead code. Your tests for the false path are also dead. When you remove the flag, remove the tests for the removed path. This is part of flag cleanup.

If you are trying to identify stale flags, we wrote about how to clean up feature flag technical debt.

What we do

In our own codebase, every flag has its own test file. The file tests both branches plus targeting rules. When we remove the flag, we remove the test file. Git blame tells us when and why.


Flagify supports React, Node.js, and NestJS, each with testing utilities. See the quick start or browse the SDK docs for testing patterns specific to your framework.

Start for free — no credit card required.