skip to content

Network Mocking

You will learn how to give a frontend test a predictable backend and, more importantly, how to choose which layer to fake so the test still exercises real code. Interviewers care because mocking your own fetch wrapper versus mocking the network is the difference between a test that proves something and one that does not.

on this pageshow

questions

19

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

In Mock Service Worker (MSW) v2 a request handler is written as http.get('/api/users', resolver). What are the two halves of that handler, what must the resolver return, and does the component's own fetch() code have to change?

level: juniorimportance: must knowfreq 55%

basics

~20 s

An MSW handler pairs a predicate (method plus path, e.g. http.get('/api/users')) with a resolver function that returns a Response, usually built with HttpResponse.json(). Application code is untouched — MSW answers the real request at the network boundary.

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

Your frontend tests stub API responses with hand-written JSON fixtures. How do you make the build fail when the backend's OpenAPI or GraphQL schema renames a response field, and where does that protection stop?

level: middleimportance: must knowfreq 58%

basics

~20 s

Generate types from the API schema as a build step and annotate every fixture with the generated type, so a renamed or removed field fails typecheck. That only catches shape changes present in the schema copy you last regenerated — never runtime values or semantics.

open as a page

Why can a frontend test that mocks the application's own API-client module stay green while the feature is broken for real users, and what changes when the fake is moved down to the network boundary instead?

level: middleimportance: must knowfreq 62%

basics

~20 s

Mocking your own client deletes the code that talks to the server, so the test can only confirm your assumptions about it. Faking at the network boundary keeps that code running and replaces only the server, so request shape, parsing and error handling are exercised for real.

open as a page

Mock Service Worker (MSW) is set up with setupWorker in the browser and setupServer in Node. What actually intercepts the request in each environment, and why can both be handed the same array of handlers?

level: middleimportance: must knowfreq 62%

basics

~20 s

In the browser a registered service worker script catches outgoing requests and asks the MSW client to resolve them. In Node there is no service worker, so MSW patches the request-issuing primitives instead. Handlers are environment-free values, so both setups share them.

open as a page

A frontend suite that stubs every network call stayed green through a backend release that broke the app in production. Explain how this mock drift happens and what you would add so the suite fails instead.

level: seniorimportance: must knowfreq 52%

basics

~20 s

Stubs freeze the contract as it was when they were written, and nothing rechecks them, so a fully stubbed suite verifies the app against its own stale assumption. Detection requires deriving stubs from the schema, validating stub payloads against it at test time, and keeping a few real calls.

open as a page

In a frontend test suite, twelve tests each paste the same thirty-line user JSON object and change one field. What is a test data factory, and what do default values plus per-test overrides fix about those tests?

level: juniorimportance: should knowfreq 50%

basics

~20 s

A test data factory is a function that returns a valid default payload and merges any per-test overrides into it. Each test then states only the field it cares about, duplicated JSON disappears, and the payload's shape has exactly one place to change.

open as a page

A frontend component test replaces the app's own API-client module with a fake that returns a canned user object. Which parts of the application's code does that test no longer exercise?

level: juniorimportance: should knowfreq 45%

basics

~20 s

Everything below the fake goes untested: URL and query building, headers and auth, request serialization, response parsing, status and error mapping, plus any caching or retry logic inside the client. Only the code above the seam actually runs.

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

In an MSW-based suite, one test calls server.use() to make GET /api/orders return an empty list, and a later test that expects the normal payload starts failing. Why does the override leak, and what is the standard fix?

level: middleimportance: should knowfreq 50%

basics

~20 s

server.use() adds a runtime handler that takes precedence over the initial ones and stays for the rest of the process. Call server.resetHandlers() in afterEach so every test starts from the shared baseline, or register the override as a one-time handler.

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

Some teams record real API responses into fixture files and replay them in frontend tests; others hand-write the smallest stub the component needs. What does each approach buy and cost, and how would you choose?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Recorded responses give real fidelity but are large, unreviewable, may carry secrets, and rot silently. Minimal hand-written stubs are readable and intent-revealing but encode only what the author believed. Most teams record once to learn the shape, then reduce it to a typed factory.

open as a page

A frontend team replaces its HTTP client library with a different one and changes no user-visible behavior, yet hundreds of tests fail. What does that tell you about where those tests placed their fakes, and where should the seam have been?

level: seniorimportance: should knowfreq 40%

basics

~20 s

The fakes were bound to the implementation — the client library or the app's own module — so swapping it invalidated them even though behavior was unchanged. A fake placed at the network boundary describes URLs and payloads instead, and survives any client that speaks HTTP.

open as a page

In a test suite using Mock Service Worker (MSW), what happens by default to a request that matches no handler, and why do teams start it with onUnhandledRequest: 'error'?

level: seniorimportance: should knowfreq 40%

basics

~20 s

By default MSW warns and lets the request proceed to the real network, so an unmocked call fails late and obscurely or quietly hits a live API. Setting onUnhandledRequest to 'error' fails the test at the exact mismatch instead.

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

Your frontend test suite fakes the network in every single test, so no test ever talks to a real server. What fidelity risk have you accepted, and how would you decide where in the portfolio to pay for a real backend?

level: principalimportance: should knowfreq 33%

basics

~20 s

You have accepted that every response in the suite is your own belief about the API, so the whole suite can stay green while the server changes underneath it. Closing that requires a small, deliberately funded band of tests running against a real backend on the riskiest flows.

open as a page

Your app posts every GraphQL query to the single endpoint /graphql. In Mock Service Worker (MSW), why would you write graphql.query('GetUser', resolver) rather than an http.post('/graphql', resolver) handler?

level: middleimportance: nice to knowfreq 32%

basics

~20 s

Every GraphQL call is a POST to the same URL, so method-plus-path cannot tell GetUser from GetOrders. MSW's graphql handlers read the operation out of the request and match on its type and name, giving the resolver the variables too.

open as a page

Six frontend apps in your organisation each maintain their own fixtures for the same internal API. How would you decide whether to invest in a single schema-derived fixture package, and what would you watch for after adopting one?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Decide from evidence of harm: how often duplicated fixtures caused incidents, and how much rework each API change costs across six repos. A shared package pays off only if it is generated from the schema and ships factories, not frozen payloads — otherwise it becomes a seventh, more confident copy.

open as a page