skip to content

Error & Latency Simulation

You will learn to test the states real users actually hit — a 500, a timeout, a slow spinner, an aborted request — which are exactly the code paths a happy-path mock never touches. Interviewers ask because error and loading UI is where frontend bugs really live.

on this pageshow

questions

5

A component fetches a list from an API, and every test in its suite stubs that call with a successful response. What is left untested, and how would you write a test that covers the failure path?

level: juniorimportance: must knowfreq 62%

answer

  1. only one branch ever executes
  2. the mock is a test input
  3. override for this test, then reset
  4. assert the message the user sees
  5. absence needs a different query

basics

~20 s

Happy-path stubs leave the error and empty branches unexecuted, so bugs there ship unnoticed. Override the stub in one test so the endpoint fails, then assert the visible error message appears and the list does not.

solid answer

~50 s

If every stub returns data, the only code path the suite ever runs is the success branch — the error message, the retry button and the empty state are effectively dead code as far as the tests are concerned, and they are where most real UI bugs live. The fix is to treat the mock as a per-test input rather than fixture furniture: keep a default success handler for the suite, and in the failure test override that one endpoint to answer with an error status or a transport failure. Then assert what the user would see — the error text is rendered, the list rows are gone, and the spinner is no longer showing — using an absence-safe query such as `queryByRole` for the things that should not be there. Don't assert that the fetch mock was called; that tests the mock, not the component.

code

javascript · 18 lines
javascript
import { http, HttpResponse } from 'msw'
import { screen, render } from '@testing-library/react'
import { server } from './test-server'
import { ProductList } from './ProductList'

test('renders an error and no rows when the API fails', async () => {
  server.use(
    http.get('/api/products', () =>
      HttpResponse.json({ message: 'boom' }, { status: 500 })
    )
  )

  render(<ProductList />)

  expect(await screen.findByRole('alert')).toHaveTextContent(/could not load/i)
  expect(screen.queryByRole('listitem')).not.toBeInTheDocument()
  expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
})

go deeper

for a junior

Be ready to say plainly that a suite stubbing only successful responses never runs the error branch, and to show one test that makes the endpoint fail and asserts the error text is on screen.

for a middle

Explain the two-layer handler setup — realistic defaults for the suite, a scoped override inside the failing test — and why the assertion belongs on rendered output rather than on the mock's call log.

for a senior

Show judgment about which failures are worth simulating for a given screen, and insist that the failure test also pins the absence of stale success UI and of a stuck spinner, since those are the defects that reach production.

for a principal

Own the convention: where default handlers live, how overrides are reset, and how failure fixtures stay contract-accurate so error-path tests keep their value as the API evolves.

## Why a happy-path-only suite is weaker than it looks A data-fetching component is really a small state machine: pending, success, failure, and usually empty. A test suite that stubs every request with a 200 and a populated body drives exactly one of those transitions. Coverage tools will still report high line coverage — the component file is executed, the imports run, the success JSX renders — while the branch that renders the error message may never execute once. This is the single most common gap in frontend test suites, and it is why interviewers ask about it: error and loading UI is written once, rarely exercised by the developer in the browser, and then silently rots. ## Make the stub a per-test input The practical technique, independent of which mocking tool you use, is a two-layer setup: 1. A **default set of handlers** registered for the whole suite, returning realistic success responses. This keeps the ordinary tests short — they say nothing about the network because they don't care. 2. A **per-test override** for the endpoint under examination. In a network-interception library you re-register a handler for that one route inside the test; with a module-level double you queue a rejected or non-OK response for the next call. Whatever the mechanism, the override must be scoped to the test and reset afterwards, or the failure you injected leaks into the next test and produces a mystery failure somewhere else in the file. There are two distinct failures worth stubbing, and they usually hit different branches of your client code. One is a **response that arrived but was not successful** — the server answered with a 4xx or 5xx status. The other is a **request that never produced a response at all** — a transport-level failure, the shape of thing you see when the connection is refused or the request is aborted. Client wrappers often handle only one of the two, so simulate both and see whether your component's error branch actually renders in each case. ## Assert what the user sees, not what the mock did The weak version of this test asserts `expect(fetchMock).toHaveBeenCalled()` or reaches into component state for an `error` field. Both pass even if the error message never reaches the screen, and both break the moment you refactor the fetch call into a hook. The durable assertion is on rendered output: ```js await screen.findByRole('alert') expect(screen.queryByRole('listitem')).not.toBeInTheDocument() ``` Note the asymmetry between the two query families. `findBy*` returns a promise and retries until the element appears, which is what you want for something that arrives after the failed request settles. `getBy*` throws when there is no match, so it can never express "this should be absent" — for absence you need `queryBy*`, which returns `null` instead of throwing. ## What the failure test should cover A good failure test asserts three things together: - **The error affordance appeared.** The message the user reads, ideally found by its accessible role or its text, not by a CSS class. - **The success UI is gone.** A component that renders both a stale list and an error banner is a real bug, and only the negative assertion catches it. - **The pending UI ended.** The classic defect here is a `finally` block that was never written, leaving the spinner up forever behind the error text. Assert the spinner is no longer present. If the component offers recovery — a retry button — the test continues: change the handler back to a success response, click retry, and assert the list renders. That single test now covers the failure branch, the recovery branch, and the fact that the two are wired together. ## Keep the failure realistic Stub the error the way your server actually answers: the same status, and the same error body shape your API returns, so that error-message extraction is exercised rather than skipped. A stub that returns an empty body when production returns `{ "message": "..." }` will happily pass while the real UI renders `undefined`. Deriving that shape from the same source as your success fixtures is what keeps the failure stub honest over time.

  • Why not just register the failing handler globally and let every test see it?
    Because the failure is the exception, not the baseline. A global failure handler forces every other test to fight it, and a global override registered in one file can leak into another when the mock server is shared. Default to success, override narrowly inside the one test, and reset handlers between tests so each case starts from a known network.
  • The component renders both the error banner and the previous list at once. Which assertion in your test would have caught that?
    The negative one. Asserting only that the error banner appeared passes happily while stale rows sit underneath it. Pairing the positive assertion with `expect(screen.queryByRole('listitem')).not.toBeInTheDocument()` pins down the whole visual state rather than one element of it, which is what the user actually experiences.
  • Is it worth stubbing the exact error body your API returns, or is an empty body good enough?
    Stub the real shape. If production sends `{ message }` and your stub sends nothing, the code that extracts and displays the server's message is never executed, so the test passes while the UI renders `undefined`. Making failure fixtures as contract-accurate as success fixtures is what keeps the error path honestly covered.

Testing only the success response is like fire-drilling a building by walking out of the front door in good weather — the alarm, the exit signs and the stairwell are exactly the parts nobody has checked.

saying these in an interview costs you the question

  • Says error states are too rare to be worth testing.
  • Asserts the fetch mock was called instead of checking rendered output.
  • Registers the failing stub globally, so it leaks into other tests.
  • Uses getBy to assert an element is absent, which throws instead.
  • Checks internal error state rather than what the user sees.

context

open as a page

A test stubs a component's API call with an instantly-resolved response, so its loading skeleton disappears before any assertion can run. How do you make the pending state observable in a test without adding a fixed sleep?

level: middleimportance: must knowfreq 58%

basics

~20 s

Take control of when the stubbed response settles: either inject a delay into the handler, or hand the test a promise it resolves by hand. Assert the skeleton while the request is pending, release the response, then assert it is replaced by the data.

open as a page

An endpoint behind a product list page returns HTTP 200 with an empty array. Why is that worth a separate test from both the populated response and the error response, and what should that test assert?

level: middleimportance: should knowfreq 40%

basics

~20 s

An empty successful result is a third state with its own UI: not data, not an error. Stub the endpoint to return an empty collection and assert the empty-state message renders, while the error banner, the spinner and any row are all absent.

open as a page

A data client retries a failed request three times with exponential backoff, and the test that asserts the final error UI takes seven seconds and fails intermittently. How do you test the retry and backoff behaviour without waiting real time?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Replace real waiting with a virtual clock the test advances, and make the mock answer a scripted sequence — fail, fail, succeed. Assert the number of attempts and the UI at each stage, and shrink the backoff schedule through configuration rather than sleeping.

open as a page

A frontend suite simulates only a generic 500 for every endpoint it stubs. As the lead, how would you decide which additional failure and latency conditions are worth encoding as tests, and where each belongs?

level: principalimportance: should knowfreq 32%

basics

~20 s

Simulate a failure when the product renders a distinct response to it. Enumerate the branches the code actually takes, cover each once at the cheapest level that exercises it, and leave broad fault-injection matrices out of the functional suite.

open as a page