Skip to content
Intermediate11 min study

Mocking, test doubles, and async tests

Use the right test double at the right boundary and eliminate flakiness instead of waiting around it.

Question progress0 / 10 completed
Start the lesson
Mocking, test doubles, and async tests visual explanation

WHAT YOU WILL BE ABLE TO DO

Learning outcomes

  • Explain mocking, test doubles, and async tests in plain language.
  • Connect the behavior to the underlying browser or framework model.
  • Implement the core pattern and reason through edge cases.
  • Answer common follow-ups without relying on memorized phrases.

01Explain it simply

A test double replaces a real dependency for a test: a stub returns canned data, a mock asserts it was called correctly, a spy records calls on a real implementation, and a fake is a lightweight working substitute such as an in-memory database. Choosing the wrong one couples tests to implementation details.

One-line definition: Use the right test double at the right boundary and eliminate flakiness instead of waiting around it.

02Mental model

Mock at the boundary you don't own or that's slow or nondeterministic — network, time, randomness — not at the boundary you're actually testing. Mocking too deep, such as mocking your own component's internal function, tests that the mock was called, not that the feature works.

03Step by step

  • Identify the actual boundary that's slow, external, or nondeterministic.
  • Mock network requests at the request layer, not the function that calls fetch internally.
  • Use fake timers to control setTimeout or debounce instead of real waits.
  • Wait for an observable UI outcome rather than a fixed sleep.
  • Reset mocks between tests so state doesn't leak across cases.

04Working example

JavaScript
test('shows results after search resolves', async () => {  server.use(http.get('/api/search', () => HttpResponse.json({ items: ['cat'] })));  render(<Search />);  await userEvent.type(screen.getByRole('textbox'), 'ca');  expect(await screen.findByText('cat')).toBeInTheDocument();});

The mock intercepts the actual network request, which is the real external boundary, rather than mocking a useSearch hook. findByText waits for the real async resolution and rerender instead of an arbitrary sleep, so the test is both realistic and non-flaky.

05Where it is used

  • Isolating tests from flaky or rate-limited third-party APIs
  • Testing debounce or throttle logic with fake timers
  • Verifying an analytics call fired with the right payload using a spy
  • Swapping a real database for an in-memory fake in integration tests

06Common mistakes

  • Mocking so deep that the test only verifies the mock was called, not real behavior
  • Using a fixed setTimeout or sleep in a test instead of waiting for an observable condition
  • Forgetting to restore or reset mocks, causing state to leak into the next test
  • Mocking time-dependent code without controlling the clock, producing intermittent failures

07Interview answer

Justify where you mock by pointing at the actual slow or external boundary — a test that mocks its own component's internals is a smell interviewers will probe.

A test mocks the component's internal useOrderTotal hook to return 100 and asserts the total renders. What does this test actually fail to verify?

It never exercises the real calculation logic in useOrderTotal — it only proves the component renders whatever the hook returns, so a bug in the actual total calculation would slip through undetected.

DDConcept deep dives

Deep dive 1

Mock at the boundary you don't own

The most durable place to intercept is the actual external or nondeterministic dependency — the network call, the system clock, the random number generator — not an internal function your own code calls on the way there. Mocking your own abstraction usually just proves the mock was invoked, leaving the real logic inside that abstraction untested.

  • A network-level mock like MSW preserves the real code path up to the wire.
  • Mocking your own hook or utility function often removes the exact logic under test.
  • The right mock boundary is the one that's actually slow, external, or nondeterministic.

Deep dive 2

Deterministic control beats waiting longer

Real timers, real randomness, and real network variance introduce timing that differs between test runs and CI environments, which is what makes tests flaky. Fake timers, seeded randomness, and mocked network responses let a test control every variable and assert on an exact, reproducible outcome instead of hoping a fixed delay was long enough.

  • A fixed sleep in a test is a guess, not a guarantee.
  • Waiting for an observable UI outcome adapts automatically to actual timing.
  • Flakiness is a symptom of uncontrolled nondeterminism, and should be root-caused like a bug.

Deep dive 3

Test isolation prevents state leaking between cases

A mock's configured behavior, call history, or a fake's stored data can silently persist into the next test if not reset or recreated, making failures depend on run order or which tests ran before — a much harder class of bug to diagnose than a straightforward incorrect assertion.

  • Reset or recreate mocks and fakes between tests as a default habit.
  • Order-dependent test failures are a strong signal of leaked shared state.
  • Isolated setup makes each test's failure attributable to that test alone.

QAInterview questions and model answers

Attempt each answer aloud before opening it. The model answer shows the depth and precision expected in an interview; it is not a script to memorize.

Intermediate · Conceptual · 1 min · Question 1What is the difference between a stub, a mock, and a spy?Open model answer

Model answer

A stub returns canned data without caring how it's called. A mock is a stub that also asserts specific calls and arguments happened. A spy wraps a real implementation and records calls while still letting the real behavior run unless configured otherwise.

Open question page →
Intermediate · Conceptual · 1 min · Question 2Why is mocking at the network layer usually better than mocking a data-fetching hook?Open model answer

Model answer

Mocking the network preserves the real hook's logic — loading states, error handling, caching — under test, and it exercises the same code path production traffic uses, so a bug in the hook itself would still be caught.

Open question page →
Intermediate · Conceptual · 1 min · Question 3How do fake timers help test debounce or throttle logic?Open model answer

Model answer

They let a test advance simulated time instantly and deterministically, instead of waiting real milliseconds or seconds, so the exact firing boundary can be asserted quickly and without flakiness from real scheduler jitter.

Open question page →
Intermediate · Conceptual · 1 min · Question 4What's wrong with asserting after a fixed setTimeout(resolve, 500) in a test?Open model answer

Model answer

It's both slow and unreliable — too short and it flakes under CI load, too long and it wastes time on every run. Waiting for an actual observable condition adapts to real timing instead of guessing a number.

Open question page →
Intermediate · Conceptual · 1 min · Question 5Why should mocks be reset between tests?Open model answer

Model answer

A mock's configured return value or call history can leak into the next test if not reset, causing one test's setup to silently affect another test's assertions and producing failures that only appear depending on run order.

Open question page →
Intermediate · Conceptual · 1 min · Question 6What is a common cause of flaky tests around async code?Open model answer

Model answer

Uncontrolled nondeterminism — real timers, real randomness, unmocked network variance, or race conditions between two async operations — makes outcomes depend on timing that differs between runs instead of being deterministic.

Open question page →
Beginner · Conceptual · 1 min · Question 7What is the difference between jest.fn() and jest.spyOn()?Open model answer

Model answer

jest.fn() creates a brand-new mock function with no prior implementation, while jest.spyOn() wraps an existing method on an object, by default still calling through to the real implementation unless configured otherwise.

Open question page →
Advanced · Conceptual · 1 min · Question 8Why might a test intentionally use a fake instead of a mock for a database?Open model answer

Model answer

A fake, such as an in-memory implementation of the same interface, lets integration-style tests exercise realistic query and transaction behavior without asserting on specific call arguments the way a strict mock would.

Open question page →
Advanced · Conceptual · 1 min · Question 9What problem does request-level mocking (like MSW) solve that mocking fetch directly doesn't?Open model answer

Model answer

It intercepts at the network layer regardless of which client library issues the request, so the same mock handlers work whether the code uses fetch, axios, or a generated client, and real request/response semantics are preserved.

Open question page →
Intermediate · Conceptual · 1 min · Question 10Why can testing with real timers make a debounce test slow across a large suite?Open model answer

Model answer

Each test would need to actually wait out the real debounce delay, and those delays accumulate across many tests; fake timers let the same assertions run in milliseconds regardless of the configured delay.

Open question page →

SCScenario questions

Scenario 1

A test suite mocks an entire useCart hook to test the checkout button, and a real bug in the cart total calculation shipped to production undetected.

  1. Identify that mocking the hook removed real logic from test coverage.
  2. Move the mock boundary down to the actual network or storage call the hook depends on.
  3. Keep the hook's real implementation running under test.
  4. Add a focused unit test directly on the total calculation as well.
Reveal worked answer

The mock was placed too deep — it replaced the exact logic that had the bug. I would mock only the network request the hook makes internally, letting the real useCart logic run against that fake response, and add a dedicated unit test for the total calculation so that specific logic has direct coverage too.

Verify and go deeper