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.
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 byuseEffectinside aSuspenseboundary can produceact()warnings even when usingwaitFor. Wrap async assertions inawait act(async () => { … })or upgrade to@testing-library/reactv14+ which handles concurrent mode transitions automatically. vi.mockhoisting withbeforeEachoverrides. Vitest hoistsvi.mockcalls to the top of the file before any imports. If you try to conditionally mock a module insidebeforeEach, the hoist runs first and the condition is ignored. Always set the mock return value inbeforeEachusingvi.mocked(fn).mockReturnValue(…)rather than re-callingvi.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. Usevi.resetModules()inbeforeEachand 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.
Related
- Isolation Principles — the parent cluster covering the full range of environment-decoupling techniques this page implements one slice of.
- Mock Boundaries — how to design contract-based interceptors between your component and external services.
- Setting Up Mock APIs for Frontend Component Tests — MSW handler patterns and request lifecycle management in depth.
- Managing Component State During Automated Tests — state injection patterns for Redux, Zustand, and React Query that complement the render wrapper approach above.
- Component Testing Fundamentals — the top-level guide to the entire testing workflow this page sits within.