skip to content

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