Handling API Errors in React Tests With MSW
Testing error handling requires returning HTTP error status codes (400, 401, 404, 500) and error response bodies from MSW handlers, then verifying that your React component displays the error message and does not crash. Real production bugs often occur in error-handling paths that are rarely tested, making comprehensive error testing essential for reliability and user experience.
When building a checkout flow at Nexus Commerce, we discovered that our error handling only tested the "happy path." MSW's ability to inject realistic error responses revealed that we were not handling payment-gateway timeouts or displaying retry buttons. Error state testing became our highest-impact testing investment.
Returning HTTP Error Responses
MSW handlers return error responses the same way they return success responses: by specifying a status code and a body. The key is choosing a realistic status code that your component can act on.
import { http, HttpResponse } from "msw";
export const handlers = [
// 404 Not Found
http.get("/api/articles/:id", ({ params }) => {
if (params.id === "not-found") {
return HttpResponse.json(
{ error: "Article not found" },
{ status: 404 }
);
}
return HttpResponse.json({
id: params.id,
title: "Article Title",
content: "Content here",
});
}),
// 401 Unauthorized (missing auth)
http.get("/api/user", ({ request }) => {
const auth = request.headers.get("Authorization");
if (!auth) {
return HttpResponse.json(
{ error: "Unauthorized: missing Authorization header" },
{ status: 401 }
);
}
return HttpResponse.json({ id: "user-1", name: "Alice" });
}),
// 403 Forbidden (insufficient permissions)
http.delete("/api/admin/users/:id", ({ request, params }) => {
const role = request.headers.get("X-User-Role");
if (role !== "admin") {
return HttpResponse.json(
{ error: "Forbidden: admin access required" },
{ status: 403 }
);
}
return HttpResponse.json({ deleted: true, id: params.id });
}),
// 500 Internal Server Error
http.post("/api/feedback", () => {
return HttpResponse.json(
{ error: "Server error: unable to process feedback" },
{ status: 500 }
);
}),
];
By returning error status codes, you ensure your component's error-handling code paths execute. Many developers skip error testing because returning a success response is simpler, but this leaves critical code paths untested.
Testing Error Message Display
Your component should display user-friendly error messages when the API returns an error. Use MSW to return specific error responses and verify the UI updates accordingly.
function ArticleDetail({ articleId }) {
const [article, setArticle] = React.useState(null);
const [error, setError] = React.useState(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetch(`/api/articles/${articleId}`)
.then((res) => {
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json();
})
.then((data) => {
setArticle(data);
setError(null);
})
.catch((err) => {
setError(err.message);
setArticle(null);
})
.finally(() => setLoading(false));
}, [articleId]);
if (loading) return <div>Loading...</div>;
if (error) {
return (
<div role="alert" className="error-container">
<h2>Error</h2>
<p>{error}</p>
</div>
);
}
return (
<article>
<h1>{article.title}</h1>
<p>{article.content}</p>
</article>
);
}
test("displays error when article not found", async () => {
render(<ArticleDetail articleId="not-found" />);
// Error message should appear
const error = await screen.findByRole("alert");
expect(error).toBeInTheDocument();
expect(error).toHaveTextContent(/API error: 404/);
});
test("displays article when it exists", async () => {
render(<ArticleDetail articleId="article-1" />);
// Article content should appear
const title = await screen.findByRole("heading", { level: 1 });
expect(title).toHaveTextContent("Article Title");
// Error should not be present
const error = screen.queryByRole("alert");
expect(error).not.toBeInTheDocument();
});
By testing both success and error paths, you catch bugs where error handling is missing or incomplete. The test verifies that the error message is visible and that the article content is not rendered.
Simulating Network Timeouts
Network timeouts (when a request never completes) require a different approach: MSW's HttpResponse.error() function simulates a network failure.
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/slow-endpoint", () => {
// Simulate a network failure (timeout, DNS error, etc.)
return HttpResponse.error();
}),
];
function SlowComponent() {
const [data, setData] = React.useState(null);
const [error, setError] = React.useState(null);
React.useEffect(() => {
fetch("/api/slow-endpoint")
.then((res) => res.json())
.then(setData)
.catch((err) => {
setError("Network error: unable to reach server");
});
}, []);
if (error) {
return <div role="alert">{error}</div>;
}
if (!data) return <div>Loading...</div>;
return <div>{data.message}</div>;
}
test("handles network timeout gracefully", async () => {
server.use(
http.get("/api/slow-endpoint", () => {
return HttpResponse.error();
})
);
render(<SlowComponent />);
// Network error message should appear
const error = await screen.findByRole("alert");
expect(error).toHaveTextContent(/network error/i);
});
HttpResponse.error() simulates a network failure without needing to mock timeouts. Your component's .catch() handler runs immediately, allowing you to verify error UI without artificial delays.
Testing Retry Logic
Many components implement retry buttons for transient errors. Use server.use() to simulate failure on the first request and success on the retry.
let requestCount = 0;
test("retries failed request on user click", async () => {
server.use(
http.get("/api/data", () => {
requestCount++;
// Fail on first request, succeed on second
if (requestCount === 1) {
return HttpResponse.json(
{ error: "Temporary error" },
{ status: 503 }
);
}
return HttpResponse.json({
data: [{ id: "1", name: "Item" }],
});
})
);
render(<RetryableComponent />);
// First request fails
const error = await screen.findByRole("alert");
expect(error).toBeInTheDocument();
// User clicks retry
const retryButton = screen.getByRole("button", { name: /retry/i });
await userEvent.click(retryButton);
// Second request succeeds
const item = await screen.findByText("Item");
expect(item).toBeInTheDocument();
// Error should disappear
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
This pattern verifies that your retry mechanism works correctly and that the error state is properly cleared after a successful retry. It is more realistic than always succeeding on the first try.
Testing Error Recovery With useEffect Dependencies
Components that re-fetch on state change should clear errors when the request changes. This prevents stale errors from being displayed.
function SearchResults({ query }) {
const [results, setResults] = React.useState([]);
const [error, setError] = React.useState(null);
React.useEffect(() => {
setError(null); // Clear old error
setResults([]); // Clear old results
if (!query) return;
fetch(`/api/search?q=${query}`)
.then((res) => res.json())
.then(setResults)
.catch((err) => setError(err.message));
}, [query]);
if (error) return <div role="alert">{error}</div>;
if (results.length === 0) return <div>No results</div>;
return (
<ul>
{results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
);
}
test("clears error when search query changes", async () => {
// First search fails
server.use(
http.get("/api/search", ({ request }) => {
const q = new URL(request.url).searchParams.get("q");
if (q === "error-trigger") {
return HttpResponse.json(
{ error: "Search failed" },
{ status: 500 }
);
}
return HttpResponse.json({
results: [{ id: "1", title: "Result" }],
});
})
);
const { rerender } = render(<SearchResults query="error-trigger" />);
// Error appears
const error = await screen.findByRole("alert");
expect(error).toBeInTheDocument();
// Change query to a working one
rerender(<SearchResults query="working-query" />);
// Error should disappear and results should appear
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
const result = await screen.findByText("Result");
expect(result).toBeInTheDocument();
});
This test verifies that your component's useEffect properly clears error state when dependencies change, preventing UI from showing an outdated error message after a new search.
Comparison: HTTP Status Codes and Their Meaning
| Status Code | Meaning | Component Action | MSW Handler |
|---|---|---|---|
| 200 | Success | Display data | { status: 200 } |
| 400 | Bad Request | Show validation errors | { status: 400 } |
| 401 | Unauthorized | Redirect to login | { status: 401 } |
| 403 | Forbidden | Show permission error | { status: 403 } |
| 404 | Not Found | Show "not found" message | { status: 404 } |
| 500 | Server Error | Show generic error, offer retry | { status: 500 } |
| Network Error | No response | Show offline message | HttpResponse.error() |
Different status codes require different user-facing responses. Your tests should verify that your component handles each appropriately.
Key Takeaways
- MSW handlers return error responses using the same syntax as success responses, but with error status codes (400, 401, 404, 500).
HttpResponse.error()simulates network failures and timeouts, allowing you to test offline scenarios.- Error state testing catches bugs in user-facing error messages, error recovery, and retry logic.
server.use()allows per-test handler overrides, enabling you to test success and error paths in separate tests.- Clearing error state when requests change (via
useEffectdependencies) prevents stale errors from being displayed.
Frequently Asked Questions
Should every component have error handling?
Yes, unless it is guaranteed to never fail (which is rare). Network requests always have a non-zero failure rate. Even synchronous operations can throw. Best practice: test error handling for any async operation.
How do I test error handling for multiple endpoints in one component?
Use server.use() to override multiple handlers in a single test, or create a shared handler set for error scenarios: const errorHandlers = [http.get(..., () => HttpResponse.json({}, {status: 500}))]
What if my component catches the error but does not display it?
This is a bug. Components that silently fail are hard to debug in production. Your test should fail and force you to either display the error or log it for debugging.
Is it okay to test both success and error in the same test?
Generally no—separate your tests. One test per scenario (happy path, 404, 500, timeout) makes it clear which behavior is broken when a test fails.