How to isolate React components for unit testing

This page is part of the Isolation Principles cluster, which covers every technique for cutting a React component free from its environment before you assert on it.

The specific problem: you render a component in a test, and something outside that component — a global store, an unresolved fetch, a Context value left over from a previous test — changes the output. Your assertion targets the component but fails because of something elsewhere.

Problem statement

React components that import hooks, context consumers, or service modules carry invisible dependencies into every test that renders them. A useSelector call reaches into a global Redux store; a useQuery fires a real network request; a useTheme hook reads from a ThemeContext provider that may or may not exist in the test environment. None of these dependencies are visible in the component’s props signature, so tests that “feel” unit-level are actually coupled to the entire application graph.

The symptom is intermittent failure: tests pass in isolation, fail in parallel CI workers, or produce different snapshots depending on test execution order.

Root cause

The root cause is implicit coupling — the component’s render output depends on modules or context values it imports rather than values it receives via props. Without the isolation principles that enforce explicit injection, every test that renders the component is quietly an integration test. The mock boundaries that would intercept these imports are either absent or reset inconsistently between tests, so state from one test leaks into the next.

Implicit coupling vs explicit isolation Two side-by-side boxes. Left: a component box with arrows reaching out to GlobalStore, ThemeContext, and fetch() — labelled "Implicit coupling, leaks between tests". Right: a component box surrounded by a dashed boundary where all dependencies enter through a controlled render wrapper — labelled "Explicit isolation, deterministic output". Implicit coupling <UserCard /> component under test GlobalStore ThemeContext fetch() leaks between tests Explicit isolation deterministic output render(ui, { wrapper }) QueryClientProvider · ThemeProvider · MemoryRouter <UserCard /> receives only explicit deps MSW intercepts fetch · vi.mock intercepts modules

Minimal reproduction

The shortest test that demonstrates the problem: a component that reads from a context provider that is simply not rendered in the test:

// UserCard.tsx — reads from ThemeContext and fires a query
import { useTheme } from '@/context/ThemeContext';
import { useQuery } from '@tanstack/react-query';
import { fetchUser } from '@/services/api';

export function UserCard({ userId }: { userId: string }) {
  const { theme } = useTheme();                         // implicit context dep
  const { data } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
  return <div data-theme={theme}>{data?.name ?? '…'}</div>;
}
// UserCard.test.tsx — no providers, no MSW → will throw or fetch a real URL
import { render, screen } from '@testing-library/react';
import { UserCard } from './UserCard';

test('shows user name', async () => {
  render(<UserCard userId="42" />);        // ThemeContext is undefined → throws
  // fetchUser fires a real HTTP request → fails in CI
});

Running this produces either Cannot read properties of undefined (reading 'theme') or a network error in CI, depending on whether the context throws or silently returns undefined.

Step-by-step fix

1. Configure the test runner for strict isolation

Turn on pool: 'forks' so each worker gets a fresh module registry, and add a setupFiles entry that fails immediately on any console error or unhandled promise — this surfaces hidden coupling on the first run rather than after three retries.

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    pool: 'forks',               // separate process per worker — no shared module state
    setupFiles: ['./src/test/setupTests.ts'],
  },
});
// src/test/setupTests.ts
import { vi, beforeEach, afterEach } from 'vitest';
import { server } from './mocks/server';    // MSW server — defined in step 3

// Hard-fail on console.error so coupling shows up immediately
vi.spyOn(console, 'error').mockImplementation((...args) => {
  throw new Error(`console.error during test: ${args.join(' ')}`);
});

// Clean DOM and mocks between every test
beforeEach(() => {
  document.body.innerHTML = '';
  vi.clearAllMocks();
});

// MSW lifecycle
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

What this does: any component that fires an unexpected network request or logs a React warning now fails the test immediately rather than silently polluting the next one.

2. Build a controlled render wrapper

Replace direct calls to @testing-library/react’s render with a custom wrapper that injects every provider in a known initial state. This is the single most effective isolation technique — the component receives explicit context rather than finding whatever happens to be mounted above it.

// src/test/render.tsx
import { render as rtlRender, RenderOptions } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/context/ThemeContext';

interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
  theme?: 'light' | 'dark';
  initialRoute?: string;
  queryClient?: QueryClient;
}

export function render(
  ui: React.ReactElement,
  {
    theme = 'light',
    initialRoute = '/',
    queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }),
    ...rest
  }: CustomRenderOptions = {}
) {
  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <MemoryRouter initialEntries={[initialRoute]}>
        <QueryClientProvider client={queryClient}>
          <ThemeProvider initialTheme={theme}>{children}</ThemeProvider>
        </QueryClientProvider>
      </MemoryRouter>
    );
  }
  return rtlRender(ui, { wrapper: Wrapper, ...rest });
}

// re-export everything else from RTL so tests only import from this file
export * from '@testing-library/react';

What this does: every test that imports render from ./test/render gets a fresh QueryClient (no cached responses from previous tests), an explicit theme, and a clean router — without needing to set up any of that per-test.

3. Intercept the network boundary with MSW

Mock boundaries at the network layer prevent real HTTP calls from leaving the test process. MSW intercepts at the fetch/XMLHttpRequest level, which means the component code changes nothing — only the test environment does.

// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users/:id', ({ params }) =>
    HttpResponse.json({ id: params.id, name: 'Ada Lovelace', role: 'admin' })
  ),
  http.post('/api/users', () =>
    HttpResponse.json({ id: '99', name: 'New User' }, { status: 201 })
  ),
];
// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// server.listen() / resetHandlers() / close() are called in setupTests.ts (step 1)

What this does: onUnhandledRequest: 'error' in setupTests.ts means any fetch call that does not match a handler throws immediately, making the missing mock visible rather than causing a silent hang or a flaky timeout.

4. Mock module-level hooks with a centralized registry

For hooks that are imported at the module boundary (analytics, feature flags, auth state), use vi.mock at the top of the test file and keep default return values in a shared factory. This replaces the entire module before the component imports it.

// src/test/factories/hookFactories.ts
import { vi } from 'vitest';

export const mockUseAuth = (overrides = {}) => ({
  user: { id: '1', name: 'Ada Lovelace' },
  isAuthenticated: true,
  logout: vi.fn(),
  ...overrides,
});

export const mockUseFeatureFlag = (flags: Record<string, boolean> = {}) =>
  (flag: string) => flags[flag] ?? false;
// UserCard.test.tsx — full isolated test using every layer above
import { screen, waitFor } from '@testing-library/react';
import { render } from '@/test/render';          // custom wrapper
import { UserCard } from './UserCard';
import { mockUseAuth } from '@/test/factories/hookFactories';
import { vi } from 'vitest';

vi.mock('@/hooks/useAuth', () => ({ useAuth: vi.fn() }));

import { useAuth } from '@/hooks/useAuth';

beforeEach(() => {
  vi.mocked(useAuth).mockReturnValue(mockUseAuth());
});

test('renders authenticated user name from API', async () => {
  render(<UserCard userId="42" />);
  await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeInTheDocument());
});

test('renders loading state before data resolves', () => {
  render(<UserCard userId="42" />);
  expect(screen.getByText('…')).toBeInTheDocument();
});

What this does: each test declares exactly what the outside world looks like — no ambient state, no shared mutable singletons.

Verification

Run the suite and watch for these signals that isolation is working:

npx vitest run --reporter=verbose

Expected terminal output when isolation is correct:

 ✓ UserCard > renders authenticated user name from API  (42ms)
 ✓ UserCard > renders loading state before data resolves  (8ms)

 Test Files  1 passed (1)
 Tests       2 passed (2)
 Duration    1.20s

If MSW’s onUnhandledRequest: 'error' catches a real fetch, the output will name the URL:

Error: [MSW] Cannot bypass a request without a matching request handler:
  • GET https://api.example.com/users/42

That is the intended behavior — it tells you exactly which boundary is uncovered, so you can add a handler rather than let the request slip through silently.

Add a coverage threshold in vitest.config.ts to gate CI on boundary coverage:

test: {
  coverage: {
    provider: 'v8',
    thresholds: {
      branches: 80,
      functions: 85,
      lines: 85,
    },
  },
}

Edge cases and caveats

  • React 18 concurrent mode and act() warnings. Under React 18, state updates triggered by useEffect inside a Suspense boundary can produce act() warnings even when using waitFor. Wrap async assertions in await act(async () => { … }) or upgrade to @testing-library/react v14+ which handles concurrent mode transitions automatically.
  • vi.mock hoisting with beforeEach overrides. Vitest hoists vi.mock calls to the top of the file before any imports. If you try to conditionally mock a module inside beforeEach, the hoist runs first and the condition is ignored. Always set the mock return value in beforeEach using vi.mocked(fn).mockReturnValue(…) rather than re-calling vi.mock.
  • Module isolation with vi.resetModules(). If your component uses a module that caches state at import time (e.g. a singleton logger or a global event emitter), vi.clearAllMocks() will not reset that cached state. Use vi.resetModules() in beforeEach and re-import the module dynamically inside the test to get a truly fresh instance.

FAQ

Why does isolation matter if my tests already pass?

Tests that pass today because of coincidental ambient state will fail when test execution order changes — in CI, when you add more tests, or when you upgrade the test runner. Explicit isolation makes that accidental dependency visible before it becomes a production incident.

Can I use jest.mock instead of vi.mock in a Vitest project?

No. jest.mock is not available in Vitest — it will throw a ReferenceError. The API is vi.mock (from vitest) or the __mocks__ directory convention, both of which behave identically to Jest’s auto-mock system.

My component uses React Router’s useNavigate — do I need to mock it?

Wrapping in MemoryRouter (as the custom render wrapper does in step 2) is sufficient for useNavigate, useLocation, and useParams. You only need vi.mock('react-router-dom', …) if you want to assert that navigate was called with specific arguments — in that case, mock just the useNavigate return value while keeping the real MemoryRouter.