Testing React context providers in isolation

This page is part of the state injection section of the component testing fundamentals guide, which covers giving a component exactly the inputs a scenario needs.

The specific problem: a component calls useAuth(), which reads from an AuthContext. In a test there is no provider, so the hook returns the context’s default value — or throws — and every assertion is about the wrong state.

Problem statement

React context is invisible in a component’s props signature. useAuth() looks like a local call, and nothing in the component’s type tells a reader that the render depends on a provider somewhere above it. In the application that provider is mounted once at the root; in a test it is absent unless the test mounts it.

The symptom is one of two failures. Either the hook throws “useAuth must be used within an AuthProvider”, or — worse — it silently returns a default and the component renders the signed-out state while the test asserts on the signed-in one.

Root cause

createContext(defaultValue) supplies a value to any consumer with no provider above it. That default exists for convenience and is almost never a state the application actually uses, so a test that relies on it is asserting against a configuration production never sees.

The temptation is to make the default useful, and then to mutate it per test. That is the isolation failure the section warns about: the default is module state, shared by every test in the process, so the mutation outlives the test that made it.

The provider tree a test builds A boundary labelled render wrapper containing five layers. A MemoryRouter seeded with an initial entry, a fresh QueryClient with retries disabled, a ThemeProvider defaulting to light and overridable per test, an AuthContext provider whose value is built per test and highlighted, and finally the component under test receiving all four. Render wrapper MemoryRouter initialEntries: ['/cart'] QueryClientProvider new client, retry: false ThemeProvider theme: 'light' (overridable) AuthContext.Provider value built per test Component under test receives all four Every layer is constructed inside the wrapper call, so no test shares an instance

Minimal reproduction

// AuthContext.tsx
type Auth = { user: User | null; signOut: () => void };
export const AuthContext = createContext<Auth>({ user: null, signOut: () => {} });
export const useAuth = () => useContext(AuthContext);
// renders the signed-out branch, because there is no provider
it('shows the display name', () => {
  render(<AccountMenu />);
  expect(screen.getByText('Ada Lovelace')).toBeInTheDocument();   // fails
});
// worse: makes it pass by mutating shared module state
AuthContext._currentValue = { user: ada, signOut: vi.fn() };      // leaks everywhere

Step-by-step fix

1. Build the value inside the test

function renderWithAuth(ui: ReactElement, value: Partial<Auth> = {}) {
  const auth: Auth = { user: null, signOut: vi.fn(), ...value };
  const view = render(<AuthContext.Provider value={auth}>{ui}</AuthContext.Provider>);
  return { ...view, auth };            // hand it back so the test can assert on it
}

What this does: constructs a fresh object per render, so no two tests can observe the same value, and returns it for assertions about the callbacks it carries.

2. State each scenario as a named value

const ada: User = { id: '42', name: 'Ada Lovelace', role: 'member' };
const admin: User = { id: '7', name: 'Grace Hopper', role: 'admin' };

it('shows the display name when signed in', () => {
  renderWithAuth(<AccountMenu />, { user: ada });
  expect(screen.getByText('Ada Lovelace')).toBeInTheDocument();
});

it('shows the admin link for an admin', () => {
  renderWithAuth(<AccountMenu />, { user: admin });
  expect(screen.getByRole('link', { name: /admin/i })).toBeInTheDocument();
});

What this does: each test declares the exact context it needs, and a reader can see the scenario without opening another file.

3. Render the real provider when it has behaviour worth testing

If the provider does more than hold a value — refreshing a token, persisting a session — inject into it rather than replacing it.

render(
  <AuthProvider initialUser={ada}>
    <AccountMenu />
  </AuthProvider>,
);

What this does: keeps the provider’s own logic under test while still starting from a known state. Replacing it with a bare value provider would leave that logic unexercised.

4. Assert on the callbacks the context supplied

it('calls signOut on click', async () => {
  const { auth } = renderWithAuth(<AccountMenu />, { user: ada });

  await userEvent.click(screen.getByRole('button', { name: /sign out/i }));

  expect(auth.signOut).toHaveBeenCalledTimes(1);
});

What this does: proves the wiring between the rendered control and the context’s function, which is the part a snapshot cannot see.

5. Make a missing provider fail loudly

export const AuthContext = createContext<Auth | null>(null);

export function useAuth(): Auth {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used within an AuthProvider');
  return ctx;
}

What this does: removes the silently-wrong default entirely, so a test that forgets the provider fails with a message naming the cause rather than rendering the wrong branch.

Verification

Four context values, four independent tests A Vitest run of AccountMenu.test.tsx. Four tests pass covering the signed-out state, the signed-in display name, the admin link, and the sign-out click handler. The context value was constructed four times, once per test, so none inherits another's object. vitest run src/AccountMenu.test.tsx PASS shows Sign in when signed out 9 ms PASS shows the display name when signed in 12 ms PASS shows the admin link for an admin 11 ms PASS calls signOut on click 15 ms context value constructed 4 times Test Files 1 passed (1)
vitest run src/AccountMenu.test.tsx --sequence.shuffle

Shuffling confirms independence. If a seed fails, a context value is being shared — usually a fixture object that one test mutated instead of replacing.

Edge cases and caveats

How to supply a context, by what it contains A decision node asking what the context gives the component. Plain data is supplied by injecting a value built per test. Data plus behaviour is supplied by rendering the real provider seeded with initial state, so its logic stays under test. A third-party client is supplied by rendering the real provider around a fake client, so the library's own code still runs. Only when nothing is under your control is mocking the hook the answer. What is the context giving the component? Inject a value build it per test plain data Real provider, seeded keeps its logic under test data plus behaviour Real provider, fake client library code still runs a third-party client vi.mock the hook last resort nothing you control
  • Nested providers of the same context. The nearest provider wins, so a test wrapping its own value around a component that already sits inside an app-level provider silently uses the inner one. Render the component directly rather than inside a page that supplies its own.

  • useContext in a custom hook tested alone. Testing the hook without a component still needs the provider — pass it as the wrapper option to renderHook, which mounts it around the hook exactly as it would around a component.

  • Context values that change identity every render. A provider passing an object literal creates a new value on every render, re-rendering every consumer. This shows up in tests as an unexpected render count; memoise the value in the provider rather than working around it in the test.

FAQ

Should I mock useAuth instead of rendering a provider?

Rendering a provider is better in almost every case. Mocking the hook removes the context’s own code from the test, so a provider bug — a wrong default, a memoisation mistake, a missing refresh — stays invisible. It also ties the test to the hook’s import path, which fails open when the module moves. Reserve hook mocking for third-party contexts you cannot construct.

How do I supply several contexts without a deeply nested render call?

Compose them once. Export an AllProviders component that nests the router, query client, theme and auth in the same order the application does, and accept the per-test overrides as props. Testing Library’s wrapper option then takes a single component, and — because Storybook decorators take the same shape — the identical tree can serve both, which removes a whole class of “works in Storybook, not in the test” surprises.

Is it acceptable to give the context a useful default so tests need no provider?

No. The default becomes shared mutable state the moment a test needs a different value, and the failure it causes is order-dependent and hard to trace. A null default with a throwing hook is the safer design: it costs one wrapper call per test and makes a missing provider impossible to overlook.