How to Test Error States in Your Frontend Application
The happy path gets tested every time someone opens the app. The error paths get tested when production breaks. That is backwards: an error screen is the one screen a user sees when they are already frustrated, and it is usually the least reviewed code in the project. The reason is simple: errors are hard to produce on demand. The backend works, so you cannot see what happens when it does not.
This guide lists the failures worth testing, what a good UI does for each, and how to produce every one of them in the browser without changing a line of backend code.
The failures worth testingโ
Not every status code deserves its own screen, but every kind of failure deserves a decision. These are the ones that show up in real apps:
| Failure | What the user should see |
|---|---|
500, 502, 503 | A clear message, a retry action, and the rest of the page still working |
404 on a detail page | A "not found" state, not an infinite spinner or a blank card |
401 mid-session | A redirect to sign in, ideally back to where they were afterwards |
403 | "You do not have access", not a generic error |
422 / 400 on a form | The server's validation messages next to the right fields, input kept |
429 | A "slow down" message, and a retry that honours Retry-After |
| Network error (offline, DNS) | An offline message, and recovery when the connection comes back |
| Timeout / very slow response | A loading state that does not flicker, then a timeout with a retry |
| Malformed JSON | An error state, not an uncaught exception and a white screen |
| Valid JSON, wrong shape | Missing fields handled: no undefined on screen, no crash |
| Empty list | An empty state that tells the user what to do next |
Go through this table with your designer once. Every row you skip is a decision made by default, usually by a stack trace.
Three ways to produce an errorโ
1. In codeโ
Return the error from a mocked handler in your test suite (Mock Service Worker, a stubbed fetch, a fixture server). This is the right place for the cases you want checked on every commit: one test per important row of the table above.
The limit is that someone has to write and maintain each case, and nobody writes a handler to try something once.
2. In DevToolsโ
The browser's developer tools can block a request (Network request blocking), slow the tab down (throttling), take it offline, and override a response's content locally (Overrides). They are already installed and they are good for a quick look. The limits: blocking only produces a network error, not a 500; overrides work on a saved copy of a response and take a few steps to set up; and the setup lives in one DevTools window. See our Network tab guide for where each of these lives.
3. In a browser extensionโ
An extension like tweak sits between the page and the network, so you describe the failure as a rule and the page receives it as if the server had sent it. No code, no backend, works on localhost, staging and production alike. The rest of this guide uses it.
Recipesโ
Each recipe is one rule. Create it in the tweak panel, refresh the page, look at the result, switch the rule off.
A server error (500, 503)โ
Create a Mock rule for the endpoint, set its status to 500, and give it the body your API really returns on failure:
{ "error": "internal_error", "message": "Something went wrong" }
Now check: is there a message? A retry button? Does the retry call the endpoint again? Does the rest of the page still work, or did one failing widget take the whole route down?
Our older post How to simulate an HTTP request error walks through this case with a demo app.
An error on the real responseโ
Sometimes you want the server's real answer with only the status changed, to see how the UI treats a 500 whose body is a normal payload. A Modify rule calls the real server and overrides the status, a field or a header (Modify rules are on Tweak Basic and Tweak Max). See How to override an HTTP status code.
Session expired (401)โ
Mock the endpoint your app calls after a period of inactivity (often the user or token refresh endpoint) with status 401. The right behaviour is a sign-in screen that brings the user back where they were. The common bug is a loop: the app retries, gets 401, retries again.
Validation errors (422)โ
Mock the form's submit endpoint with 422 and the error shape your backend uses:
{
"errors": {
"email": ["This email is already registered"],
"password": ["Must be at least 12 characters"]
}
}
Check that each message lands next to its field, that what the user typed is still there, and that the submit button is usable again.
Rate limited (429)โ
Mock the endpoint with status 429, and add a Retry-After: 30 response header on the rule's Response headers tab. Does the app tell the user to wait, or does it retry in a tight loop and make things worse?
Network failureโ
A network error is not a status code: the request never gets an answer, and fetch rejects instead of resolving. Your error handling for this lives in a different branch of the code (a catch, not an if (!response.ok)), and it is often missing.
Create a Block rule for the endpoint. The browser cancels the request and the page sees exactly what it would see on a dropped connection. To test the whole app offline, the toolbox's network tool takes the tab offline in one click.
Slow responses and timeoutsโ
Set a delay on a Mock rule (for example 8000 ms) to see the loading state for as long as you want. Check that the skeleton does not jump when the data arrives, that the user can still navigate away, and, if your client has a timeout, that the timeout message shows up. See How to simulate a delayed HTTP request.
To slow the whole tab down to a real connection (Slow 3G, hotel wifi), use the toolbox's network tool instead.
Malformed JSONโ
Mock the endpoint with status 200 and a body that is not valid JSON:
{"items": [{"id": 1, "name": "Mug"
tweak will warn you on the rule that the payload is invalid, and serve it anyway, because here that is the point. A good app shows an error state. A fragile one throws inside response.json() and leaves a white screen.
Valid JSON, wrong shapeโ
This is the error that ships to production most often, because the API "worked". Mock the response with a field missing or null:
{ "id": 42, "name": null, "price": { "amount": 1999 } }
Here the currency is gone and the name is null. Look for undefined, NaN, null and empty strings on screen, and for crashes deeper in the tree. With a Modify rule you can do this on the real response, removing one field and keeping the rest.
Empty statesโ
Mock the list endpoint with [] (or { "items": [], "total": 0 }). An empty state is not an error, but it is a state nobody sees in development because the dev database is never empty.
Make it a routineโ
- Keep a collection of failure rules per feature: "Checkout: 500", "Checkout: offline", "Checkout: slow". Collections (Tweak Basic and Tweak Max) group them; on the free plan, a clear label on each rule does the job.
- Share them. Export the rules to a JSON file and commit it, so QA and the next developer get the same failures.
- Test the error states in review. A pull request that adds a screen should say what the screen does on a
500, on a network error and on an empty response. With the rules ready, that takes a minute to check. - Promote the important ones to automated tests. When a failure case matters every release, write it as a handler in your test suite too.
Summaryโ
Every failure in the table above can be produced in the browser, on demand, without the backend's help: a status code and a body for server errors, a blocked request for network errors, a delay for slow responses, a broken or reshaped body for parsing errors. The only thing standing between your app and good error handling is how easy it is to see the errors, and that part is solved.
Install tweak and create your first error rule. Mock and Block rules are free.
If you liked this article, consider sharing (tweeting) it to your followers.
Need something else? Request a feature
