Skip to content

React · Testing

Mocking Async in React Tests — Complete Guide

10 min read Updated 2026-06-24 Share:

Practice Mocking Async interview questions

Why async testing is the hardest part of React testing

Most React components that matter fetch data, update after timers expire, or react to network errors. Testing them means controlling time and the network inside a test environment — a domain that trips up even experienced engineers.

This guide covers every async testing pattern that shows up in interviews: mocking fetch, setting up Mock Service Worker, testing loading and error states, fake timers, module mocking, React Query, and debounce.

Two schools of HTTP mocking

You have two broad options for intercepting HTTP calls in tests:

1. Mock fetch or axios directly

global.fetch = vi.fn().mockResolvedValue({
  ok: true,
  json: async () => ({ id: 1, name: 'Alice' }),
})

Simple to set up. The downside: you're testing that your component calls fetch with certain arguments, which couples the test to the HTTP layer. If you switch from fetch to axios, every test breaks even if the behavior is identical.

2. Mock Service Worker (MSW) — intercept at the network level

MSW installs a service worker (browser) or Node.js interceptor (tests) that intercepts matching HTTP requests before they leave the process. Your component uses the real fetch or axios and gets realistic responses.

For everything beyond trivial one-component tests, MSW is the right choice.

Setting up MSW for Vitest

npm install -D msw

src/mocks/handlers.ts

import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users', () =>
    HttpResponse.json([
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ])
  ),
  http.get('/api/users/:id', ({ params }) =>
    HttpResponse.json({ id: Number(params.id), name: 'Alice' })
  ),
  http.post('/api/login', async ({ request }) => {
    const { password } = await request.json()
    if (password === 'secret') {
      return HttpResponse.json({ token: 'abc123' })
    }
    return new HttpResponse(null, { status: 401 })
  }),
]

src/mocks/server.ts

import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

src/test/setup.ts

import { server } from '../mocks/server'
import { beforeAll, afterEach, afterAll } from 'vitest'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

onUnhandledRequest: 'error' fails the test if your component makes an unexpected API call — this catches forgotten mocks early.

Testing loading states

Always test three phases: loading indicator visible → data visible → loading indicator gone.

test('shows spinner while loading, then user list', async () => {
  render(<UserList />)

  // Loading indicator appears synchronously (before any await)
  expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument()

  // Wait for data
  expect(await screen.findByText('Alice')).toBeInTheDocument()
  expect(screen.getByText('Bob')).toBeInTheDocument()

  // Loading indicator is gone
  expect(screen.queryByRole('status', { name: /loading/i }))
    .not.toBeInTheDocument()
})

To test a realistic slow network without waiting in real time, use MSW's delay helper:

import { delay } from 'msw'

server.use(
  http.get('/api/users', async () => {
    await delay(200)
    return HttpResponse.json([{ id: 1, name: 'Alice' }])
  })
)

Testing error states

Override the MSW handler for a specific test to return an error:

test('shows error alert on 500', async () => {
  server.use(
    http.get('/api/users', () => new HttpResponse(null, { status: 500 }))
  )
  render(<UserList />)
  expect(await screen.findByRole('alert')).toHaveTextContent(/something went wrong/i)
})

test('shows network error message', async () => {
  server.use(
    http.get('/api/users', () => HttpResponse.error())  // network failure
  )
  render(<UserList />)
  expect(await screen.findByText(/network error/i)).toBeInTheDocument()
})

After each test, server.resetHandlers() removes these one-off overrides so they don't bleed into the next test.

Error states to always test:

  • Success (2xx) — happy path.
  • Client error (400/404) — "not found", "validation failed".
  • Server error (500) — "something went wrong, try again".
  • Network failure — no connection, timeout.

Mocking fetch directly

When you don't need MSW (trivial components, quick unit tests):

import { vi } from 'vitest'

beforeEach(() => {
  global.fetch = vi.fn()
})

afterEach(() => {
  vi.restoreAllMocks()
})

test('fetches user and displays name', async () => {
  global.fetch.mockResolvedValue({
    ok: true,
    json: async () => ({ id: 1, name: 'Alice' }),
  })

  render(<UserProfile userId={1} />)
  expect(await screen.findByText('Alice')).toBeInTheDocument()
  expect(global.fetch).toHaveBeenCalledWith('/api/users/1')
})

Fake timers

For components that use setTimeout, setInterval, debounce, or throttle, use vi.useFakeTimers() to take control of the clock.

describe('Countdown', () => {
  beforeEach(() => vi.useFakeTimers())
  afterEach(() => vi.useRealTimers())  // ALWAYS restore

  test('decrements every second', () => {
    render(<Countdown seconds={5} />)
    expect(screen.getByText('5s remaining')).toBeInTheDocument()

    act(() => vi.advanceTimersByTime(1000))
    expect(screen.getByText('4s remaining')).toBeInTheDocument()

    act(() => vi.advanceTimersByTime(3000))
    expect(screen.getByText('1s remaining')).toBeInTheDocument()
  })
})

Wrap vi.advanceTimersByTime in act() so React flushes the resulting state updates before assertions run.

For async timers (timer fires and then triggers a Promise):

await act(async () => {
  vi.advanceTimersByTime(1000)
})

Testing debounced components

Debounce tests need both fake timers AND the correct userEvent configuration so userEvent's internal delays also use fake time:

beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())

test('calls onSearch only after debounce window', async () => {
  const onSearch = vi.fn()
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })

  render(<SearchBox onSearch={onSearch} debounceMs={300} />)

  await user.type(screen.getByRole('textbox'), 'react')

  // Nothing yet — debounce window hasn't elapsed
  expect(onSearch).not.toHaveBeenCalled()

  vi.runAllTimers()

  expect(onSearch).toHaveBeenCalledTimes(1)
  expect(onSearch).toHaveBeenCalledWith('react')
})

The { advanceTimers: vi.advanceTimersByTime } option tells userEvent to use your fake clock for its own internal delays, preventing timing mismatches.

Module mocking with vi.mock()

For non-HTTP dependencies — analytics, feature flags, third-party SDKs:

import { vi } from 'vitest'
import * as analytics from '../analytics'
import Dashboard from './Dashboard'

vi.mock('../analytics')   // auto-mocks all exports

const mockedAnalytics = vi.mocked(analytics)

beforeEach(() => {
  mockedAnalytics.trackPageView.mockImplementation(() => {})
})

afterEach(() => vi.clearAllMocks())

test('tracks page view on mount', () => {
  render(<Dashboard />)
  expect(mockedAnalytics.trackPageView).toHaveBeenCalledWith('/dashboard')
})

vi.mock() is hoisted before imports, so the mock is in place before Dashboard imports analytics.

For a partial mock (keep some real exports):

vi.mock('../utils/date', async (importOriginal) => {
  const real = await importOriginal()
  return { ...real, formatDate: vi.fn().mockReturnValue('Jun 24, 2026') }
})

Testing React Query

Create a fresh QueryClient per test to prevent cache contamination between tests:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

function createTestClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false },    // no retries in tests
      mutations: { retry: false },
    },
  })
}

function renderWithQuery(ui) {
  const client = createTestClient()
  return render(
    <QueryClientProvider client={client}>{ui}</QueryClientProvider>
  )
}

test('useQuery renders data', async () => {
  server.use(
    http.get('/api/posts', () =>
      HttpResponse.json([{ id: 1, title: 'Hello World' }])
    )
  )
  renderWithQuery(<PostList />)
  expect(await screen.findByText('Hello World')).toBeInTheDocument()
})

With retry: false, a failed query immediately shows the error state rather than retrying three times (the default), which keeps tests fast.

Testing localStorage

The jsdom environment provides a real localStorage implementation. Clear it after each test:

afterEach(() => localStorage.clear())

test('saves theme preference', async () => {
  const user = userEvent.setup()
  render(<ThemeToggle />)
  await user.click(screen.getByRole('button', { name: /dark mode/i }))
  expect(localStorage.getItem('theme')).toBe('dark')
})

test('reads saved theme on mount', () => {
  localStorage.setItem('theme', 'dark')
  render(<ThemeToggle />)
  // Button should show "switch to light" since dark is already active
  expect(screen.getByRole('button', { name: /light mode/i })).toBeInTheDocument()
})

To simulate localStorage being unavailable:

vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
  throw new DOMException('QuotaExceededError')
})

Suppressing console.error

Error boundaries and async failures cause React to log errors. Suppress them to keep test output readable:

test('shows error boundary fallback', () => {
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

  render(
    <ErrorBoundary>
      <ThrowingComponent />
    </ErrorBoundary>
  )

  expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
  consoleSpy.mockRestore()    // always restore!
})

Always call mockRestore() — leaving it in place silently swallows real errors in subsequent tests.

The "act warning" and how to fix it

The warning appears when a state update happens outside React's test flushing cycle:

Warning: An update to Component inside a test was not wrapped in act(...)

Common fixes:

  • await every userEvent call.
  • Use findBy* (which internally awaits) instead of getBy* for async elements.
  • Wrap timer advances in act(() => vi.advanceTimersByTime(...)).
  • Cancel timers and subscriptions in useEffect cleanup.

Testing race conditions

Simulate an older request resolving after a newer one:

let requestCount = 0
server.use(
  http.get('/api/search', async ({ request }) => {
    requestCount++
    if (requestCount === 1) {
      await delay(500)   // slow first request
      return HttpResponse.json({ results: ['Stale'] })
    }
    return HttpResponse.json({ results: ['Fresh'] })  // fast second request
  })
)

// Type one query, then change it quickly
await user.type(input, 'slow')
await user.clear(input)
await user.type(input, 'fast')

// Should show only the fresh result
expect(await screen.findByText('Fresh')).toBeInTheDocument()
expect(screen.queryByText('Stale')).not.toBeInTheDocument()

This test verifies the component uses AbortController or an ignore flag to cancel stale responses.

Interview checklist

Before your interview, make sure you can:

  • Set up MSW with setupServer, handlers, and the test lifecycle hooks.
  • Test loading, success, and error states for fetched data.
  • Override MSW handlers per test with server.use().
  • Use vi.useFakeTimers() and vi.advanceTimersByTime() for timer tests.
  • Configure userEvent.setup({ advanceTimers }) for debounce tests.
  • Mock entire modules with vi.mock() and configure implementations per test.
  • Create a fresh QueryClient per React Query test.
  • Clear localStorage after each test.
  • Suppress console.error for expected errors and restore afterward.

Rule of thumb: MSW > direct fetch mocking for HTTP; vi.useFakeTimers > real waiting for timers; always restore mocks and clear storage in afterEach.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel