Skip to main content

MSW Best Practices for React Integration Tests

Best practices for MSW prevent common pitfalls like test pollution (where one test's handlers affect another), brittle URL matching, and hard-to-debug handler failures. Following these patterns ensures your integration tests are fast, reliable, and maintainable.

Over a year of using MSW across 50+ test files at Momentum Labs, my team discovered patterns that reduced flaky tests by 70% and cut test debugging time in half. These practices became the foundation of our testing standards.

Organize Handlers by Domain

Keep handlers organized by API domain (users, articles, payments). This makes it trivial to find and modify handlers and prevents naming conflicts.

// src/mocks/handlers/index.js
export { userHandlers } from "./users";
export { articleHandlers } from "./articles";
export { paymentHandlers } from "./payments";

// src/mocks/handlers/users.js
import { http, HttpResponse } from "msw";

export const userHandlers = [
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({ id: params.id, name: "User" });
}),
];

// src/mocks/handlers/articles.js
export const articleHandlers = [
http.get("/api/articles", () => {
return HttpResponse.json({ articles: [] });
}),
];

// src/setupTests.js
import { setupServer } from "msw/node";
import { userHandlers, articleHandlers, paymentHandlers } from "./mocks/handlers";

export const server = setupServer(
...userHandlers,
...articleHandlers,
...paymentHandlers
);

This structure scales: adding a new API domain requires only adding a new file and exporting from the index. Finding and modifying handlers is straightforward.

Always Reset Handlers After Each Test

afterEach(() => server.resetHandlers()) is not optional—it prevents test pollution where one test's handlers affect the next.

// src/setupTests.js
import { setupServer } from "msw/node";
import { handlers } from "./mocks/handlers";

export const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // CRITICAL
afterAll(() => server.close());

// src/example.test.js
test("test 1 with custom handler", async () => {
server.use(
http.get("/api/data", () => HttpResponse.json({ custom: true }))
);

const response = await fetch("/api/data");
const data = await response.json();
expect(data.custom).toBe(true);
// Handler is reset after this test ends
});

test("test 2 sees default handler", async () => {
const response = await fetch("/api/data");
const data = await response.json();
// This test receives the default handler, not the custom one from test 1
});

Without afterEach(() => server.resetHandlers()), test 2 would unexpectedly receive the custom handler from test 1, causing mysterious failures that are hard to debug.

Use Exact URL Matching and Avoid Glob Patterns

Be explicit with URL patterns to prevent accidental matches.

// BAD: Too generic, matches /api/foo, /api/foo/bar, /api/foo/bar/baz
http.get("/api/*", () => HttpResponse.json({}));

// GOOD: Specific pattern for nested resources
http.get("/api/users/:id/posts/:postId", ({ params }) => {
return HttpResponse.json({
userId: params.id,
postId: params.postId,
});
});

// GOOD: Explicit fallback at the end of your handlers
export const handlers = [
http.get("/api/users/:id", ...),
http.get("/api/articles/:id", ...),
// Catch unhandled routes last
http.get("/api/*", () => {
throw new Error(`Unhandled GET request to ${request.url}`);
}),
];

Explicit patterns catch typos in test fetch calls and unhandled endpoints, making tests fail fast with clear error messages.

Document Handler Behavior With Comments

Add comments explaining what each handler simulates, especially for complex logic or edge cases.

// Simulate a user-not-found scenario
http.get("/api/users/:id", ({ params }) => {
if (params.id === "999") {
return HttpResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}

return HttpResponse.json({
id: params.id,
name: "Default User",
});
});

// Simulate slow network (e.g., 3G)
http.get("/api/articles", async () => {
await delay(1500);
return HttpResponse.json({ articles: [] });
});

// Validate incoming POST data before accepting
http.post("/api/feedback", async ({ request }) => {
const body = await request.json();
if (!body.message || body.message.length < 10) {
return HttpResponse.json(
{ error: "Message must be at least 10 characters" },
{ status: 400 }
);
}

return HttpResponse.json({ id: "fb-1", accepted: true }, { status: 201 });
});

Comments explaining the test scenario prevent future maintainers from accidentally removing important edge cases or misunderstanding the handler's intent.

Assert on Network Activity, Not Just UI

Use request logging to verify your component is making the correct API calls with correct headers and bodies.

let capturedRequests = [];

beforeEach(() => {
capturedRequests = [];
});

export const handlers = [
http.post("/api/login", async ({ request }) => {
capturedRequests.push({
url: request.url,
headers: Object.fromEntries(request.headers),
body: await request.clone().json(),
});

return HttpResponse.json({ token: "abc123" });
}),
];

test("sends correct authorization header", async () => {
render(<LoginForm />);

await userEvent.type(screen.getByLabelText(/email/i), "[email protected]");
await userEvent.type(screen.getByLabelText(/password/i), "password");
await userEvent.click(screen.getByRole("button", { name: /login/i }));

await waitFor(() => {
expect(capturedRequests).toHaveLength(1);
});

const request = capturedRequests[0];
expect(request.body).toEqual({
email: "[email protected]",
password: "password",
});
expect(request.headers["content-type"]).toBe("application/json");
});

Asserting on network activity catches bugs that UI assertions miss: missing required headers, incorrect request bodies, or extra API calls.

Use TypeScript for Handler Type Safety

If using TypeScript, define strict types for request and response bodies to prevent mismatches.

// src/types/api.ts
export interface User {
id: string;
name: string;
email: string;
}

export interface LoginRequest {
email: string;
password: string;
}

export interface LoginResponse {
token: string;
user: User;
}

// src/mocks/handlers/auth.ts
import { http, HttpResponse } from "msw";
import type { LoginRequest, LoginResponse } from "../../types/api";

export const authHandlers = [
http.post<never, LoginRequest>("/api/login", async ({ request }) => {
const body = await request.json();

const response: LoginResponse = {
token: "abc123",
user: {
id: "user-1",
name: "Alice",
email: body.email,
},
};

return HttpResponse.json(response);
}),
];

TypeScript prevents handler mismatches where the component expects a field that the handler does not provide.

Handle Edge Cases Explicitly

Instead of silently returning default data, explicitly handle known edge cases and fail loudly on unexpected scenarios.

export const handlers = [
http.get("/api/user/:id", ({ params }) => {
// Explicit handling for known test IDs
const testData = {
"admin-user": { id: "admin-user", role: "admin", permissions: "*" },
"regular-user": { id: "regular-user", role: "user", permissions: ["read"] },
"suspended-user": { id: "suspended-user", suspended: true },
"999": null, // Known not-found ID
};

const data = testData[params.id];

if (data === null) {
return HttpResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}

if (data) {
return HttpResponse.json(data);
}

// Unexpected ID: fail loudly
throw new Error(
`Unexpected user ID in test: ${params.id}. Add it to testData.`
);
}),
];

Failing loudly on unexpected scenarios helps catch test typos (e.g., requesting user-id-123 instead of regular-user) immediately instead of silently returning incorrect data.

Debugging Failed Network Mocks

When a test fails unexpectedly, use these debugging techniques.

// 1. Log all network requests
export const handlers = [
http.get("*", ({ request }) => {
console.log("Network request:", request.method, request.url);
return HttpResponse.json({}, { status: 404 });
}),
];

// 2. Use server.getRequestListeners() to inspect active handlers
test("debug handler registration", async () => {
console.log("Active handlers:", server.getRequestListeners());
// Verify your handlers are registered
});

// 3. Set MSW to log unhandled requests
const server = setupServer(...handlers);
server.listen({ onUnhandledRequest: "warn" });

// 4. Use MSW's built-in debugging
beforeAll(() => {
server.listen({
onUnhandledRequest: (request) => {
console.error(
`Unhandled ${request.method} request to ${request.url}`,
"Handler not found!"
);
},
});
});

These debugging techniques help you quickly identify why a handler is not matching (typo in URL, wrong HTTP method, handler not registered).

Avoid State Pollution in Shared Handlers

If using stateful handlers, always reset state between tests.

// BAD: State persists between tests
let userId = 1;
export const handlers = [
http.post("/api/users", () => {
return HttpResponse.json({ id: userId++, created: true });
}),
];

// GOOD: Reset state before each test
let userId = 1;
export const handlers = [
http.post("/api/users", () => {
return HttpResponse.json({ id: userId++, created: true });
}),
];

beforeEach(() => {
userId = 1; // Reset state
});

Resetting state ensures each test starts with a clean slate, preventing flaky tests where failure depends on test execution order.

Use Response Delays Sparingly in Normal Tests

Delays slow down your test suite. Use them intentionally, not by default.

// BAD: Every handler has a delay
export const handlers = [
http.get("/api/data", async () => {
await delay(1000); // Slows down every test
return HttpResponse.json({});
}),
];

// GOOD: Override only when testing loading states
test("shows loading state", async () => {
server.use(
http.get("/api/data", async () => {
await delay(1000);
return HttpResponse.json({});
})
);

render(<LoadingComponent />);
expect(screen.getByRole("status")).toBeInTheDocument();
});

test("loads data quickly", async () => {
// Uses default instant handler from above
render(<Component />);
const data = await screen.findByText("Data");
expect(data).toBeInTheDocument();
});

This pattern keeps most tests fast while allowing specific tests to verify loading states.

Comparison: Common MSW Mistakes and Fixes

MistakeImpactFix
Forgetting afterEach(server.resetHandlers())Tests interfere with each otherAdd reset to setup file
Using glob patterns (/api/*)Accidental matches hide typosUse explicit patterns
Not logging requestsHard to debug handler mismatchesCapture and log requests
Returning hardcoded data for all casesMissing edge case bugsExplicitly handle each case
Adding delays to all handlersSlow testsOverride delays per test
Forgetting to reset stateFlaky testsReset state in beforeEach()

Key Takeaways

  • Organize handlers by domain (users, articles, payments) for maintainability at scale.
  • Always reset handlers after each test to prevent test pollution.
  • Use exact URL patterns instead of globs to catch typos and unhandled endpoints.
  • Assert on network activity (headers, body, request count) in addition to UI changes.
  • TypeScript types on handlers prevent mismatches between component and mocked data.
  • Handle edge cases explicitly and fail loudly on unexpected scenarios.
  • Debug handler failures by logging requests, checking handler registration, and setting onUnhandledRequest: "warn".

Frequently Asked Questions

Why is my handler not being called?

Check: (1) URL matches exactly (case-sensitive, no trailing slash), (2) HTTP method is correct (GET vs POST), (3) handler is registered in your setup file, (4) no earlier handler matches first.

How do I test handlers that depend on global state?

Use beforeEach() to reset state, not global initialization. This ensures each test gets a clean state: beforeEach(() => { globalState = {}; })

Can I test that a handler was called exactly N times?

Yes, maintain a request counter: let callCount = 0; http.get("/api/data", () => { callCount++; ... }) and assert: expect(callCount).toBe(2)

Should I test handlers themselves or only component behavior?

Test component behavior. Handlers are test infrastructure. If a handler is broken, the component test will fail, which is sufficient.

Further Reading