Advanced MSW: Dynamic Handlers and Edge Cases
Advanced MSW patterns involve handlers that track state across requests, validate incoming data, or return different responses based on complex logic. These patterns enable realistic testing of stateful APIs (where a POST creates a resource, a GET retrieves it, and a DELETE removes it) and workflows that depend on request history or sequence.
At Digital Forge, testing a multi-step checkout flow required simulating cart state persistence, order creation, and payment processing—all within a single test. Advanced handler patterns with state tracking made this possible without a real backend.
Stateful Handlers: Simulating Database Mutations
Real APIs maintain state: creating a resource makes it available for retrieval. Your handlers can simulate this by maintaining a shared data store.
// src/mocks/handlers/cart.js
import { http, HttpResponse } from "msw";
// In-memory cart storage for the test
let cartState = {
items: [],
total: 0,
};
export const cartHandlers = [
// GET cart
http.get("/api/cart", () => {
return HttpResponse.json(cartState);
}),
// POST add item to cart
http.post("/api/cart/items", async ({ request }) => {
const body = await request.json();
const item = {
id: `item-${Date.now()}`,
...body,
price: body.price || 0,
};
cartState.items.push(item);
cartState.total += item.price;
return HttpResponse.json(item, { status: 201 });
}),
// DELETE item from cart
http.delete("/api/cart/items/:id", ({ params }) => {
const itemIndex = cartState.items.findIndex((i) => i.id === params.id);
if (itemIndex === -1) {
return HttpResponse.json(
{ error: "Item not found" },
{ status: 404 }
);
}
const item = cartState.items[itemIndex];
cartState.total -= item.price;
cartState.items.splice(itemIndex, 1);
return HttpResponse.json({ deleted: true, id: params.id });
}),
// Clear cart for test cleanup
http.post("/api/cart/reset", () => {
cartState = { items: [], total: 0 };
return HttpResponse.json({ reset: true });
}),
];
// src/components/Cart.test.js
import { server } from "../setupTests";
test("adds and removes items from cart", async () => {
render(<CartComponent />);
// Initially empty
expect(screen.getByText(/cart is empty/i)).toBeInTheDocument();
// Add item
await userEvent.click(screen.getByRole("button", { name: /add laptop/i }));
const item = await screen.findByText(/laptop/);
expect(item).toBeInTheDocument();
// Remove item
const removeBtn = within(item.closest("li")).getByRole("button", {
name: /remove/i,
});
await userEvent.click(removeBtn);
// Cart is empty again
expect(screen.getByText(/cart is empty/i)).toBeInTheDocument();
});
By maintaining state in handlers, you test the full workflow: adding items, retrieving them in subsequent requests, and removing them—all without a real backend.
Request Validation and Conditional Responses
Handlers can validate incoming request data and return different responses based on the validation results.
export const authHandlers = [
http.post("/api/login", async ({ request }) => {
const { email, password } = await request.json();
// Validate required fields
if (!email || !password) {
return HttpResponse.json(
{ error: "Email and password are required" },
{ status: 400 }
);
}
// Validate email format
if (!email.includes("@")) {
return HttpResponse.json(
{ error: "Invalid email format" },
{ status: 400 }
);
}
// Simulate auth logic
if (email === "[email protected]" && password === "correct-password") {
return HttpResponse.json(
{
token: "auth-token-abc123",
user: { id: "1", name: "Admin", email },
},
{ status: 200 }
);
}
// Invalid credentials
return HttpResponse.json(
{ error: "Invalid email or password" },
{ status: 401 }
);
}),
];
test("validates login form errors", async () => {
render(<LoginForm />);
// Submit empty form
await userEvent.click(screen.getByRole("button", { name: /login/i }));
const error = await screen.findByText(/email and password are required/i);
expect(error).toBeInTheDocument();
});
test("rejects invalid credentials", async () => {
render(<LoginForm />);
await userEvent.type(screen.getByLabelText(/email/i), "[email protected]");
await userEvent.type(screen.getByLabelText(/password/i), "wrong-password");
await userEvent.click(screen.getByRole("button", { name: /login/i }));
const error = await screen.findByText(/invalid email or password/i);
expect(error).toBeInTheDocument();
});
By validating requests in handlers, you test that your component submits correct data and handles validation errors from the server.
Request Counting and Sequence Validation
Track how many times an endpoint is called and verify the sequence of requests.
let requestLog = [];
export const analyticsHandlers = [
http.post("/api/analytics", async ({ request }) => {
const body = await request.json();
requestLog.push({
endpoint: "/api/analytics",
action: body.action,
timestamp: new Date(),
});
return HttpResponse.json({ recorded: true }, { status: 202 });
}),
];
test("tracks user interactions in correct order", async () => {
requestLog = []; // Reset log before test
render(<UserFlow />);
// Perform actions
await userEvent.click(screen.getByRole("button", { name: /start/i }));
await userEvent.type(screen.getByRole("textbox"), "input");
await userEvent.click(screen.getByRole("button", { name: /submit/i }));
// Verify request sequence
await waitFor(() => {
expect(requestLog).toHaveLength(3);
expect(requestLog[0].action).toBe("button_start_clicked");
expect(requestLog[1].action).toBe("text_input");
expect(requestLog[2].action).toBe("form_submitted");
});
});
Request logging helps catch bugs where actions are performed in the wrong order or critical requests are missing.
Rate Limiting Simulation
Simulate rate-limiting to test that your component handles 429 Too Many Requests errors gracefully.
let requestCounts = {};
export const rateLimitHandlers = [
http.post("/api/search", async ({ request }) => {
const url = request.url;
requestCounts[url] = (requestCounts[url] || 0) + 1;
// Allow 3 requests, then rate limit
if (requestCounts[url] > 3) {
return HttpResponse.json(
{
error: "Rate limit exceeded. Try again in 60 seconds.",
retryAfter: 60,
},
{ status: 429, headers: { "Retry-After": "60" } }
);
}
return HttpResponse.json({
results: ["Result for query"],
});
}),
];
test("backs off after rate limit", async () => {
requestCounts = {};
render(<SearchComponent />);
// Make 3 successful requests
for (let i = 0; i < 3; i++) {
await userEvent.type(screen.getByPlaceholderText(/search/i), `query${i}`);
await userEvent.click(screen.getByRole("button", { name: /search/i }));
const result = await screen.findByText("Result for query");
expect(result).toBeInTheDocument();
}
// 4th request hits rate limit
await userEvent.type(screen.getByPlaceholderText(/search/i), "query4");
await userEvent.click(screen.getByRole("button", { name: /search/i }));
const error = await screen.findByText(/rate limit exceeded/i);
expect(error).toBeInTheDocument();
});
Rate-limit testing ensures your component handles throttling gracefully (typically with a backoff or retry mechanism).
Conditional Responses Based on Request Path
Handle multiple variants of the same endpoint by parsing URL parameters.
export const searchHandlers = [
http.get("/api/search", ({ request }) => {
const url = new URL(request.url);
const type = url.searchParams.get("type");
const query = url.searchParams.get("q");
if (type === "articles") {
return HttpResponse.json({
results: [
{ id: "1", type: "article", title: query, content: "..." },
],
});
}
if (type === "users") {
return HttpResponse.json({
results: [{ id: "1", type: "user", name: query }],
});
}
// Default: search all
return HttpResponse.json({
results: [
{ id: "1", type: "article", title: query },
{ id: "2", type: "user", name: query },
],
});
}),
];
test("filters search results by type", async () => {
server.use(...searchHandlers);
render(<UniversalSearch />);
// Search articles only
await userEvent.selectOptions(
screen.getByRole("combobox", { name: /type/i }),
"articles"
);
await userEvent.type(screen.getByPlaceholderText(/search/i), "React");
await userEvent.click(screen.getByRole("button", { name: /search/i }));
// Only article result appears
const article = await screen.findByText(/article/);
expect(article).toBeInTheDocument();
// User result does not appear
const user = screen.queryByText(/^user$/);
expect(user).not.toBeInTheDocument();
});
This pattern tests that your component correctly passes query parameters and handles different response shapes.
Simulating Concurrent Requests
Test how components handle multiple simultaneous requests by using parallel fetches.
function DashboardComponent() {
const [user, setUser] = React.useState(null);
const [posts, setPosts] = React.useState(null);
const [comments, setComments] = React.useState(null);
React.useEffect(() => {
Promise.all([
fetch("/api/user").then((r) => r.json()),
fetch("/api/posts").then((r) => r.json()),
fetch("/api/comments").then((r) => r.json()),
]).then(([user, posts, comments]) => {
setUser(user);
setPosts(posts);
setComments(comments);
});
}, []);
if (!user || !posts || !comments) return <div>Loading...</div>;
return (
<div>
<h1>{user.name}</h1>
<p>Posts: {posts.length}</p>
<p>Comments: {comments.length}</p>
</div>
);
}
test("handles concurrent requests", async () => {
server.use(
http.get("/api/user", async () => {
await delay(200);
return HttpResponse.json({ name: "Alice" });
}),
http.get("/api/posts", async () => {
await delay(300);
return HttpResponse.json([
{ id: "1", title: "Post 1" },
{ id: "2", title: "Post 2" },
]);
}),
http.get("/api/comments", async () => {
await delay(100);
return HttpResponse.json([{ id: "1", text: "Comment" }]);
})
);
render(<DashboardComponent />);
// All requests complete in parallel (300ms total, not 600ms)
const name = await screen.findByText("Alice");
expect(name).toBeInTheDocument();
expect(screen.getByText(/posts: 2/)).toBeInTheDocument();
expect(screen.getByText(/comments: 1/)).toBeInTheDocument();
});
Concurrent request testing verifies that your component handles multiple simultaneous fetches correctly and displays data as it arrives.
Key Takeaways
- Stateful handlers maintain state across requests, simulating real database mutations (POST creates, GET retrieves, DELETE removes).
- Request validation in handlers tests that components submit correct data and handle validation errors.
- Request logging enables sequence validation, ensuring actions occur in the correct order.
- Rate limiting simulation tests graceful degradation when APIs throttle requests.
- Concurrent request testing verifies that components handle simultaneous fetches and render partial data correctly.
Frequently Asked Questions
Does state persist between tests?
No, if you do not reset it. Always reset stateful handlers in beforeEach() or create a reset endpoint: http.post("/api/reset", () => { state = {}; return HttpResponse.json({}); })
How do I test race conditions with advanced handlers?
Use different delays for the same request to cause out-of-order responses: one request with delay(100), another with delay(50). Verify your component handles the faster response correctly.
Can I log requests without affecting the response?
Yes, log the request at the beginning of the handler and then return the response normally. The request is processed synchronously before the response is sent.
How do I handle large request bodies or complex validation?
Break validation into helper functions: const errors = validateLoginForm(body); if (errors.length) return HttpResponse.json({ errors }, { status: 400 });