Mock Boundaries in Component Tests
Mock boundaries are the controlled contract layer between a component under test and everything it depends on — network calls, context providers, child components, and browser globals. This page is part of Component Testing Fundamentals and focuses on the specific implementation work of defining, enforcing, and debugging those boundaries so your test suite stays deterministic across local dev, CI, and deployment pipelines.
Prerequisites
What a mock boundary actually is
A mock boundary marks the perimeter of the component you want to evaluate. Inside that perimeter lives the real production code; outside it sits every substitute you provide — intercepted HTTP handlers, stub context values, lightweight child-component doubles. The boundary is not a single file or config flag — it is a design decision about which collaborators the component legitimately owns and which ones are external contracts.
The diagram below shows a typical boundary for a UserProfileCard component. The component logic and its direct child views are inside the boundary. The API layer, the authentication context, and the design-system icon library are outside it and get replaced with controlled fakes during tests.
Step 1 — Declare boundary contracts as Zod schemas
Write the schema for every payload that crosses the boundary before you write a single expect. This locks the contract in code and lets CI reject drift automatically.
// src/test/fixtures/boundary-contracts.ts
import { z } from 'zod';
// Describes exactly what the API must return for UserProfileCard to render
export const UserPayloadSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2).max(80),
status: z.enum(['active', 'suspended', 'loading', 'error']),
avatarUrl: z.string().url().nullable(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
export type UserPayload = z.infer<typeof UserPayloadSchema>;
// Deterministic factory — call once per test, not once per file
export const makeUser = (overrides: Partial<UserPayload> = {}): UserPayload =>
UserPayloadSchema.parse({
id: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
name: 'Ada Lovelace',
status: 'active',
avatarUrl: 'https://example.com/avatars/ada.png',
...overrides,
});
Verify it works: import makeUser() in a test file and call it with an invalid status — UserPayloadSchema.parse should throw a ZodError describing the violation.
Step 2 — Configure MSW 2 for node (Vitest/Jest)
MSW 2 uses a setupServer factory for node environments. Set onUnhandledRequest: 'error' so any request that escapes the boundary fails the test immediately rather than silently returning undefined.
// src/test/server.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { makeUser } from './fixtures/boundary-contracts';
// Default handlers — tests override these per-scenario
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json(makeUser({ id: params.id as string }));
}),
http.get('/api/users/:id/metadata', () => {
return HttpResponse.json({ plan: 'pro', createdAt: '2023-01-01' });
}),
];
export const server = setupServer(...handlers);
// src/test/setup.ts (referenced by vitest.config.ts → setupFiles)
import { server } from './server';
import { afterAll, afterEach, beforeAll } from 'vitest';
beforeAll(() =>
server.listen({
onUnhandledRequest: 'error', // crash on boundary leaks
})
);
afterEach(() => server.resetHandlers()); // never let handler state bleed
afterAll(() => server.close());
Verify it works: write a test that fetches a URL with no matching handler. The test should fail with [MSW] Cannot bypass a request ... rather than silently resolving.
Step 3 — Build a custom render wrapper
Centralising the boundary setup in one renderWithBoundaries helper guarantees every test starts from an identical provider tree. Scatter-gun wrapper props in individual tests are a reliability anti-pattern.
// src/test/render-with-boundaries.tsx
import { render, type RenderOptions } from '@testing-library/react';
import type { ReactElement, ReactNode } from 'react';
// Stub auth context — real AuthProvider would trigger network calls
const MockAuthProvider = ({ children }: { children: ReactNode }) => (
<MockAuthContext.Provider value={{ userId: 'test-user', role: 'viewer' }}>
{children}
</MockAuthContext.Provider>
);
// Stub theme — prevents CSS-variable resolution errors in jsdom
const MockThemeProvider = ({ children }: { children: ReactNode }) => (
<div data-theme="light" data-testid="theme-root">
{children}
</div>
);
const AllProviders = ({ children }: { children: ReactNode }) => (
<MockAuthProvider>
<MockThemeProvider>{children}</MockThemeProvider>
</MockAuthProvider>
);
export const renderWithBoundaries = (
ui: ReactElement,
options: Omit<RenderOptions, 'wrapper'> = {}
) => render(ui, { wrapper: AllProviders, ...options });
// Re-export testing-library utilities so tests import from one place
export * from '@testing-library/react';
Verify it works: render any component that reads from MockAuthContext. The test should not throw useContext called outside provider.
Step 4 — Write boundary-aware tests with per-test handler overrides
The default handlers (Step 2) cover the happy path. Use server.use() inside individual tests to inject edge-case responses — loading, error, empty — without touching global handler state.
// src/components/UserProfileCard/UserProfileCard.test.tsx
import { screen, waitForElementToBeRemoved } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../../test/server';
import { renderWithBoundaries } from '../../test/render-with-boundaries';
import { UserProfileCard } from './UserProfileCard';
describe('UserProfileCard — boundary scenarios', () => {
it('renders user data from the default happy-path handler', async () => {
renderWithBoundaries(<UserProfileCard userId="3fa85f64-5717-4562-b3fc-2c963f66afa6" />);
// The loading state appears first, then resolves
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));
expect(screen.getByText('Ada Lovelace')).toBeInTheDocument();
expect(screen.getByRole('img', { name: /ada lovelace avatar/i })).toBeInTheDocument();
});
it('shows the suspended banner when the API returns status=suspended', async () => {
// Per-test override — scoped to this test only
server.use(
http.get('/api/users/:id', () =>
HttpResponse.json({ id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', name: 'Ada Lovelace', status: 'suspended', avatarUrl: null })
)
);
renderWithBoundaries(<UserProfileCard userId="3fa85f64-5717-4562-b3fc-2c963f66afa6" />);
expect(await screen.findByRole('alert', { name: /account suspended/i })).toBeInTheDocument();
});
it('renders the error state when the API returns 500', async () => {
server.use(
http.get('/api/users/:id', () =>
HttpResponse.error()
)
);
renderWithBoundaries(<UserProfileCard userId="3fa85f64-5717-4562-b3fc-2c963f66afa6" />);
expect(await screen.findByText(/failed to load profile/i)).toBeInTheDocument();
});
});
Verify it works: run npx vitest run UserProfileCard. All three scenarios should pass. Add a fourth test that fetches /api/unexpected — it should throw [MSW] Cannot bypass a request because no handler exists.
Step 5 — Module-level boundary for non-HTTP dependencies
Not every boundary crosses HTTP. Date utilities, analytics SDKs, and icon libraries are synchronous module imports that must be stubbed at the module level so they do not read from process.env or fire side effects.
// src/components/UserProfileCard/UserProfileCard.test.tsx (additions)
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
// Stub the icon library so SVG imports don't break jsdom
vi.mock('@company/icon-library', () => ({
UserIcon: () => null,
StatusIcon: ({ status }: { status: string }) => <span data-testid={`icon-${status}`} />,
}));
// Freeze time so `formatRelativeDate(user.createdAt)` is deterministic
beforeEach(() => {
vi.setSystemTime(new Date('2025-01-01T12:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
Verify it works: run the suite twice. The relative-date assertion ("registered 2 years ago") should produce the same output both times.
Configuration reference
| Option | Type | Default | Effect |
|---|---|---|---|
onUnhandledRequest |
'error' | 'warn' | 'bypass' |
'warn' |
What MSW does when a request has no matching handler. Use 'error' in CI, 'warn' in local dev during initial setup. |
server.resetHandlers() |
— | — | Removes any handlers added with server.use() inside a test, restoring the initial handler list. Call in afterEach. |
server.use(...handlers) |
RequestHandler[] |
— | Prepends handlers for the current test only. Overrides a matching default handler by URL pattern. |
vi.mock(module, factory) |
string, () => object |
— | Replaces an entire module for the test file. The factory runs once; return the exact exports the component imports. |
vi.setSystemTime(date) |
Date | number |
real clock | Freezes Date.now() and new Date() to a fixed instant. Pair with vi.useRealTimers() in cleanup. |
ZodSchema.parse(value) |
unknown |
— | Validates a payload against the schema and throws ZodError on mismatch. Use in contract tests to catch drift early. |
Common pitfalls
1. Forgetting server.resetHandlers() in afterEach
If one test adds a per-test error handler via server.use() and the next test expects the happy path, it inherits the error handler instead. Always call server.resetHandlers() in afterEach, not just afterAll.
2. Using server.use() at module scope instead of inside a test
Handlers added outside a test block are permanent for the entire file but do not benefit from the automatic reset. Put scenario-specific overrides inside the test body or a beforeEach/afterEach pair.
3. Sharing a single fixture object across tests
Mutating a shared const user = makeUser() object causes one test’s state changes to corrupt the next test’s baseline. Call makeUser() inside each test or beforeEach block to get a fresh, independent copy.
4. Mocking at the wrong boundary layer
Mocking fetch directly with global.fetch = vi.fn() bypasses MSW and breaks the contract layer — MSW will no longer intercept those requests. Mock at the MSW handler layer for HTTP, at the module layer for synchronous utilities.
5. Leaving onUnhandledRequest: 'warn' in CI
A request that escapes the boundary and receives a warning locally will silently fail in CI where the network is unavailable. Set onUnhandledRequest: 'error' in CI environments — use an environment variable to switch:
server.listen({
onUnhandledRequest: process.env.CI ? 'error' : 'warn',
});
Integration points
Mock boundaries feed directly into two adjacent parts of the Component Testing Fundamentals workflow:
-
State injection relies on the same Zod schemas declared here. The boundary contract defines what shapes are valid; state injection uses those shapes to drive specific render scenarios (loading, error, edge-case data). Keep the two in sync by importing
UserPayloadSchemain both places rather than declaring parallel types. -
Test scope definition determines which components receive a full boundary setup versus a lightweight stub. Unit-level tests for a leaf
StatusBadgeneed only a prop stub; integration-level tests for a data-fetchingUserProfileCardneed the full MSW + context wrapper. Misclassifying scope is the most common reason for an over-mocked test suite that passes locally but reveals real integration bugs too late. -
Isolation principles govern the CSS and DOM environment that wraps the boundary. After you enforce network and context boundaries here, the isolation layer ensures that stylesheet injection and global DOM state do not leak between test files.
FAQ
When should I use http.passthrough() instead of a mock handler?
Use http.passthrough() only in local development when you need to verify component behaviour against a real local API server. Never use it in CI — it introduces a hard dependency on an external service, which defeats the purpose of boundary isolation. In CI every request must resolve through a declared handler.
How do I test a component that uses React Query or SWR?
Both libraries cache responses in a client singleton. Wrap each test with a fresh QueryClient instance for React Query — built in a makeQueryClient() factory with retry: false and gcTime: 0 so nothing persists between tests — or a fresh SWRConfig with dedupingInterval: 0 and provider: () => new Map() for SWR. Without this, a successful response in test A fills the cache and test B never fires a request — the MSW handler never runs, and your boundary contract goes untested. The fresh-client wrapper belongs in your renderWithBoundaries helper (Step 3) so every test gets an isolated cache automatically.
Can I share MSW handlers across multiple test files?
Yes — export them from a central src/test/handlers/ directory and import them in server.ts. Per-test overrides still use server.use() inside the test. The only rule is that the shared handlers represent the happy-path contract; edge-case scenarios stay local to the test file that exercises them.
How do I enforce that every test file has a boundary setup?
Add a custom lint rule or a Vitest global setup check that verifies setupFiles is always present in the project Vitest config. Teams using ESLint can use eslint-plugin-testing-library to flag missing cleanup calls. For the schema contract specifically, a separate vitest.contract.config.ts that runs only the Zod parse tests in CI before the main suite gives you a fast gate on API drift.
Related
- Component Testing Fundamentals — parent guide covering the full testing workflow from environment setup to CI gating
- Isolation Principles — CSS sandboxing, DOM cleanup, and test-runner scoping that wrap the mock boundary layer
- State Injection — controlled payload delivery into component hierarchies using the same Zod contracts declared here
- Test Scope Definition — deciding which components need full boundary treatment versus lightweight stubs
- Setting up Mock APIs for Frontend Component Tests — hands-on walkthrough for bootstrapping a complete MSW handler library from scratch