Testing without mocking fetch by hand, again
I’ve written vi.mock('node-fetch') or jest.spyOn(global, 'fetch') enough
times in enough projects to know it’s one of the most fragile parts of a
typical test suite — brittle to implementation details, and it breaks the
moment you switch HTTP libraries. This post is the alternative built into
the package.
The problem with mocking fetch directly
Problem: mocking at the fetch or axios level means your test knows
about HTTP implementation details that have nothing to do with what you’re
actually testing. Switch from Axios to the fetch adapter (which, per the
last post, happens automatically on edge runtimes) and every test that
mocked Axios’s specific call shape breaks — even though the actual behavior
you were testing, “does this component show the user’s name,” didn’t
change at all.
Solution: mock at the level your code actually calls things —
api.users.get(id) — not at the transport level underneath it.
import { createMockClient } from '@developerehsan/api-client/testing'
const mockApi = createMockClient({
users: {
get: async (id: string) => ({ id, name: 'Ada Lovelace', email: 'ada@x.com' }),
list: async () => [{ id: '1', name: 'Ada Lovelace' }],
},
})mockApi.users.get('42') has the exact same signature, the exact same
return shape, as the real api.users.get('42'). Your component or test code
never knows the difference — which is the whole point. You’re testing your
component’s behavior given some data, not testing the HTTP layer at all.
A real test, using it
import { render, screen } from '@testing-library/react'
import { UserProfile } from './UserProfile'
it('shows the user name once loaded', async () => {
const mockApi = createMockClient({
users: { get: async () => ({ id: '42', name: 'Ada Lovelace' }) },
})
render(<UserProfile id="42" api={mockApi} />)
expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument()
})Problem this raises: for this to work, UserProfile needs to accept
api as a prop or via context instead of importing the singleton client
directly. That’s a real design decision, not a testing-library detail —
components that hard-import the client are harder to test this way. I
default to passing the client through context at the app root and reading it
via a hook, which keeps components decoupled from which client instance —
real or mock — they’re actually talking to.
Simulating failure, not just happy paths
Problem: it’s easy to test “the API returned data, does the UI show it.” It’s just as important, and much more often skipped, to test “the API failed, does the UI handle it” — and a naive mock that always resolves successfully can’t help you here at all.
Solution: mock methods can reject, just like real ones can throw:
const mockApi = createMockClient({
users: {
get: async () => { throw new NetworkError('offline') },
},
})
render(<UserProfile id="42" api={mockApi} />)
expect(await screen.findByText(/couldn't load/i)).toBeInTheDocument()Because these are the same typed error classes from the error-handling post,
a test simulating a NetworkError genuinely exercises the same catch
branch your production code would hit for a real network failure — not a
generic thrown Error that happens to look similar.
Asserting on calls, not just return values
Sometimes the thing worth testing isn’t what the UI shows, but whether and
how a method got called — did the create-user form actually call
api.users.create with the right payload, exactly once, and not on every
keystroke.
const createSpy = vi.fn(async () => ({ id: 'new_1' }))
const mockApi = createMockClient({ users: { create: createSpy } })
render(<CreateUserForm api={mockApi} />)
await userEvent.click(screen.getByText('Create'))
expect(createSpy).toHaveBeenCalledWith({ name: 'Ada', email: 'ada@x.com' })
expect(createSpy).toHaveBeenCalledTimes(1)Since mock methods are just functions you provide, wrapping them in your
test framework’s own spy/mock function (vi.fn, jest.fn) gets you full
call assertions for free — no separate mocking system to learn on top of the
mock client itself.
What I don’t use the mock client for
Real talk: the mock client is for unit and component tests — verifying
your code’s behavior given known inputs. It is not a substitute for
integration tests against a real backend, or at minimum a contract-testing
setup that checks your assumptions about response shapes actually hold. A
mock that returns { name: 'Ada Lovelace' } will happily keep passing even
after the real backend renames that field to fullName — nothing about
mocking at this level catches that kind of drift. That’s what the runtime
validation and drift-detection from a few posts back are actually for; the
mock client and drift detection are solving different problems and neither
one substitutes for the other.
What sticks from this post
- Mock at the method level (
api.users.get) instead of the transport level (fetch/Axios) — your tests stay stable across adapter changes and test what your code actually does, not HTTP implementation details. - Design components to accept the client via prop or context, not a hard-imported singleton, or this pattern doesn’t have anywhere to plug in.
- Mock methods can throw the same typed errors real ones do, so failure-path
tests exercise the real
catchbranches. - The mock client doesn’t catch backend drift — that’s what runtime validation and scheduled drift-checking from earlier posts are for.
Next: a full API reference, laid out as a cheat sheet you can actually scan quickly instead of re-reading the whole series.
- developerehsan