ehsan.blog
~/blog/api-client-part-17-testing-mock-client — zsh
cat api-client-part-17-testing-mock-client.md

Testing without mocking fetch by hand, again

·5 min read

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.

ts
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

ts
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:

ts
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.

ts
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 catch branches.
  • 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
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.