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.
Keeping a mock honest over time
A mock boundary is a promise that the fake behaves like the real thing. The promise is true on the day you write it and decays silently afterwards, because nothing in your test suite watches production. Three practices slow that decay.
Derive the fixture from the schema, not the other way round. When the schema is the source of truth and the handler validates its fixture before returning it, a backend field that becomes nullable breaks the fixture at test time rather than in production. When the fixture is hand-written and the schema is derived from it, both move together and neither notices.
Assert that the boundary was used. A handler that is never hit because the component changed its endpoint leaves the test passing against nothing at all. onUnhandledRequest: 'error' catches the case where the component reaches an endpoint you did not mock; asserting on a request spy catches the opposite case, where the component stopped making the call entirely.
Mock at one level, not two. A component whose service module is mocked and whose HTTP is intercepted has two fakes, and the second is dead code that nobody notices is wrong. Pick the outermost boundary the test actually needs — the module edge for a unit test, the wire for an integration test — and leave everything inside it real.
The failure mode these guard against is specific and expensive: a suite that is green while the feature is broken. That is strictly worse than no tests, because it consumes the confidence a red suite would have earned.
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.
Should the mock return the full production payload or a minimal object?
Return the full shape the schema describes, populated with obviously fake values. A minimal object passes today and breaks the moment the component reads a field you did not think to include, and the resulting failure looks like a component bug rather than a fixture gap. Fake-but-complete data — recognisable names, round numbers, a fixed date — also makes a failing diff readable, because a reviewer can tell at a glance that they are looking at fixture output.
How do I mock a request the component makes on mount, before the test can call server.use?
Put the default in the base handler set rather than in the test. server.use installs an override for requests made after it runs, so a mount-time fetch races it. The base handlers registered when the server is created are active before any test body executes, so the mount-time call always resolves. Reserve server.use for changing an already-covered endpoint’s response for one scenario.
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