Testing React Components with Mocked Flag Providers

This how-to is part of React Hooks for Feature Flag State. Component tests that depend on a real flag backend are slow, non-deterministic, and break whenever the network is unavailable. OpenFeature’s in-memory provider solves this by giving you a synchronous, fully controlled flag source that lives entirely inside your test process — no HTTP calls, no polling, no SDK auth tokens required.

Without a controlled provider, your component tests make assumptions about which flag variant is active, then break silently when a colleague toggles the flag in staging or the SDK initializes too slowly for the test runner to observe. The in-memory provider lets you pin exact variants per test case, run the on-path and off-path in the same suite, and get results in milliseconds.

Prerequisites

Test harness data flow Left-to-right diagram showing a Vitest test controlling an in-memory provider, which feeds an OpenFeature FlagProvider, which wraps the component under test. Test (Vitest) sets variants In-Memory Provider FlagProvider (OpenFeature) Component Under Test controls wraps renders
The Vitest test controls the in-memory provider directly; the provider feeds a FlagProvider wrapper; the component reads flags through the normal OpenFeature client — no network involved.

Step 1 — Install OpenFeature Web SDK and the in-memory provider

npm install @openfeature/web-sdk @openfeature/in-memory-provider
# or
pnpm add @openfeature/web-sdk @openfeature/in-memory-provider

Add both packages to devDependencies if flags are only read at runtime; move them to dependencies if you use OpenFeature in production code too. The in-memory provider should always be devDependencies because it has no production use and adds no runtime overhead to your bundle.

Step 2 — Configure the in-memory provider with flag definitions

// test/setup/flagProviders.ts
import { InMemoryProvider } from '@openfeature/in-memory-provider';

export const baseFlags = {
  'web.dashboard.new-nav': {
    defaultVariant: 'off',
    variants: { on: true, off: false },
  },
  'checkout.payments.express-pay': {
    defaultVariant: 'off',
    variants: { on: true, off: false },
  },
  'web.nav.beta-header': {
    defaultVariant: 'off',
    variants: { on: true, off: false },
  },
};

export function createTestProvider(overrides: Record<string, unknown> = {}) {
  const flags = { ...baseFlags, ...overrides };
  return new InMemoryProvider(flags);
}

Centralising the base flag definitions in a setup file keeps individual test files short: each test only declares the flag it is mutating. This pattern also enforces the namespace.service.feature key convention in one place, so a typo in a key name surfaces as a test failure rather than a silent default. The OpenFeature provider architecture describes how providers resolve variants from this definition map.

Step 3 — Wrap the component under test in the OpenFeature provider

// test/helpers/renderWithFlags.tsx
import React from 'react';
import { render } from '@testing-library/react';
import { OpenFeature, OpenFeatureProvider } from '@openfeature/web-sdk';
import { InMemoryProvider } from '@openfeature/in-memory-provider';
import { createTestProvider, baseFlags } from './flagProviders';

export async function renderWithFlags(
  ui: React.ReactElement,
  flagOverrides: Record<string, { defaultVariant: string; variants: Record<string, unknown> }> = {},
) {
  const provider = createTestProvider(flagOverrides);
  await OpenFeature.setProviderAndWait(provider);

  return render(
    <OpenFeatureProvider>
      {ui}
    </OpenFeatureProvider>
  );
}

setProviderAndWait blocks until the provider emits PROVIDER_READY, so by the time render runs the flag values are synchronously available to your component. Skipping the await here is the most common source of tests that return stale default values. For patterns around client SDK initialization in production code, the same PROVIDER_READY gate applies.

Step 4 — Set variants per test case and assert both states

// components/NavBar.test.tsx
import { describe, it, expect, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { OpenFeature } from '@openfeature/web-sdk';
import { InMemoryProvider } from '@openfeature/in-memory-provider';
import { renderWithFlags } from '../test/helpers/renderWithFlags';
import { NavBar } from './NavBar';

describe('NavBar — web.dashboard.new-nav', () => {
  it('renders the new nav when the flag is on', async () => {
    await renderWithFlags(<NavBar />, {
      'web.dashboard.new-nav': {
        defaultVariant: 'on',
        variants: { on: true, off: false },
      },
    });

    await waitFor(() =>
      expect(screen.getByTestId('new-nav')).toBeInTheDocument()
    );
    expect(screen.queryByTestId('legacy-nav')).not.toBeInTheDocument();
  });

  it('renders the legacy nav when the flag is off', async () => {
    await renderWithFlags(<NavBar />); // uses base flag defaultVariant: 'off'

    await waitFor(() =>
      expect(screen.getByTestId('legacy-nav')).toBeInTheDocument()
    );
    expect(screen.queryByTestId('new-nav')).not.toBeInTheDocument();
  });
});

Each it block calls renderWithFlags independently, so variants are explicit and local to the test. Asserting both the on-variant and off-variant in the same file prevents coverage gaps where only the happy path is verified. The waitFor wrapper handles any hook that defers rendering until a flag value resolves — even though the in-memory provider is synchronous, some useFlag implementations emit a loading state first. See preventing UI flicker during hydration for patterns that eliminate that loading state in production.

Step 5 — Reset the provider between tests to avoid state leakage

// vitest.setup.ts  (or jest.setup.ts)
import { afterEach } from 'vitest';
import { OpenFeature } from '@openfeature/web-sdk';

afterEach(async () => {
  await OpenFeature.clearProviders();
});

Register this file in your Vitest config under setupFiles. OpenFeature holds its provider in a module-level singleton. Without clearProviders(), the provider registered in test A is still active when test B runs, causing the second test to evaluate against test A’s variants. This is the single most common cause of test-order-dependent failures in OpenFeature-backed suites. If you are managing multiple named providers (domains), call clearProviders() without arguments to flush all of them.

Verification

The following test file is a complete, self-contained passing suite. Copy it into your project, adjust the import paths, and run vitest run — all assertions should pass with zero network calls.

// NavBar.integration.test.tsx
import React from 'react';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { OpenFeature, OpenFeatureProvider } from '@openfeature/web-sdk';
import { InMemoryProvider } from '@openfeature/in-memory-provider';

// Minimal stub component — replace with your real NavBar import
function NavBar() {
  const { useFlag } = require('@openfeature/react-sdk');
  const { value: showNewNav } = useFlag('web.dashboard.new-nav', false);
  if (showNewNav) return <nav data-testid="new-nav">New Nav</nav>;
  return <nav data-testid="legacy-nav">Legacy Nav</nav>;
}

async function setup(variant: 'on' | 'off') {
  const provider = new InMemoryProvider({
    'web.dashboard.new-nav': {
      defaultVariant: variant,
      variants: { on: true, off: false },
    },
  });
  await OpenFeature.setProviderAndWait(provider);
  return render(<OpenFeatureProvider><NavBar /></OpenFeatureProvider>);
}

afterEach(async () => { await OpenFeature.clearProviders(); });

describe('NavBar flag variants — deterministic, no network', () => {
  it('shows new nav when web.dashboard.new-nav is ON', async () => {
    await setup('on');
    await waitFor(() => expect(screen.getByTestId('new-nav')).toBeInTheDocument());
    expect(screen.queryByTestId('legacy-nav')).not.toBeInTheDocument();
  });

  it('shows legacy nav when web.dashboard.new-nav is OFF', async () => {
    await setup('off');
    await waitFor(() => expect(screen.getByTestId('legacy-nav')).toBeInTheDocument());
    expect(screen.queryByTestId('new-nav')).not.toBeInTheDocument();
  });
});

Both tests complete in under 50 ms on a standard CI runner. No msw mocks, no environment variables, no test doubles for the SDK auth layer — the in-memory provider replaces the entire network surface.

Gotchas & Edge Cases

Troubleshooting & FAQ

Why does the hook return the default value even when I set the flag in the provider?

Three causes account for almost every occurrence of this. First, setProviderAndWait was not awaited before render — the hook ran before the provider reached PROVIDER_READY and locked in the SDK-level default. Second, the flag key string in the provider definition does not exactly match the key passed to useFlag — a single character difference silently falls through to the default. Third, clearProviders() was not called between tests and the previous test’s provider — configured with defaultVariant: 'off' — is still active. Add a console.log at the evaluation site to print the key and the raw resolved value; mismatches become obvious immediately.

How do I test a component that reads multiple flags?

Pass all required flag definitions to the provider constructor in one object. The in-memory provider accepts an arbitrary map of flag keys, so you can define web.dashboard.new-nav, checkout.payments.express-pay, and web.nav.beta-header side by side. If a test only cares about one flag, set the others to their safe default variants so the component renders predictably for the parts you are not asserting on. Avoid setting flags your component does not read — the extra definitions are harmless but add noise to the test setup that makes intent harder to read.

const provider = new InMemoryProvider({
  'web.dashboard.new-nav':       { defaultVariant: 'on',  variants: { on: true, off: false } },
  'checkout.payments.express-pay': { defaultVariant: 'off', variants: { on: true, off: false } },
  'web.nav.beta-header':         { defaultVariant: 'off', variants: { on: true, off: false } },
});

Can I use this pattern with Jest instead of Vitest?

Yes. Replace vitest imports with @jest/globals or the global Jest API, swap vitest.setup.ts for jest.setup.ts referenced in jest.config.js under setupFilesAfterFramework, and ensure your Jest transform handles TypeScript (via ts-jest or babel-jest). The OpenFeature and in-memory provider packages are framework-agnostic; they carry no Vitest-specific dependency. The afterEach(async () => await OpenFeature.clearProviders()) cleanup call is identical in both runners.