Skip to content

Mocking Async Interview Questions & Answers

13 questions Updated 2026-06-24 Share:

React async testing interview questions — mocking fetch, MSW setup, testing loading and error states, fake timers, vi.mock, React Query testing, debounce/throttle, and localStorage mocking.

Read the in-depth guideMocking Async in React Tests — Complete Guide(opens in new tab)
13 of 13

The simplest approach is to replace global.fetch with a spy before each test and restore it after.

import { render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import UserProfile from './UserProfile'

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

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

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

  render(<UserProfile userId={1} />)

  expect(await screen.findByText('Alice')).toBeInTheDocument()
  expect(screen.getByText('admin')).toBeInTheDocument()
  expect(global.fetch).toHaveBeenCalledWith('/api/users/1')
})

test('shows error when fetch fails', async () => {
  global.fetch.mockResolvedValue({ ok: false, status: 404 })

  render(<UserProfile userId={999} />)

  expect(await screen.findByText(/user not found/i)).toBeInTheDocument()
})

The downside: you're asserting the URL and response shape in every test. This couples tests to the HTTP layer. For larger projects, prefer MSW (Mock Service Worker) which intercepts at the network level.

Rule of thumb: Use global.fetch mocking for quick unit tests; switch to MSW when you have more than a handful of API calls to test.

MSW intercepts outgoing HTTP requests at the service-worker level (browser) or the Node.js http module level (tests), letting you define handlers that return fake responses — without modifying fetch or axios in your source code.

Setup for Vitest/Node:

1. Install

npm install -D msw

2. Define handlers (src/mocks/handlers.ts)

import { http, HttpResponse } from 'msw'

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

3. Create the server (src/mocks/server.ts)

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

export const server = setupServer(...handlers)

4. Wire into test setup

// src/test/setup.ts
import { server } from '../mocks/server'
import { beforeAll, afterEach, afterAll } from 'vitest'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())   // clean up per-test overrides
afterAll(() => server.close())

Now every test automatically intercepts matching requests without any per-test configuration.

Rule of thumb: Set onUnhandledRequest: 'error' — it forces you to handle every request explicitly, catching forgotten mocks before they silently return undefined.

Assert the loading indicator is present before the data arrives, then assert it's gone once the data loads.

import { render, screen } from '@testing-library/react'
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'
import UserList from './UserList'

test('shows skeleton while loading, then data', async () => {
  // Default handler from server setup will respond eventually
  render(<UserList />)

  // Loading state should appear synchronously
  expect(screen.getByRole('status', { name: /loading/i }))
    .toBeInTheDocument()
  // or: expect(screen.getByTestId('skeleton')).toBeInTheDocument()

  // Data appears after fetch completes
  expect(await screen.findByText('Alice')).toBeInTheDocument()

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

For a slow network simulation, delay the MSW response:

import { delay } from 'msw'

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

Rule of thumb: Always assert both the loading state AND the resolved state in the same test — it proves the loading indicator appears and disappears correctly.

Override the MSW handler for the specific test to return an error response, then assert the error UI.

import { render, screen } from '@testing-library/react'
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'
import UserList from './UserList'

test('shows error message 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 not-found message on 404', async () => {
  server.use(
    http.get('/api/users/999', () => new HttpResponse(null, { status: 404 }))
  )
  render(<UserProfile userId={999} />)
  expect(await screen.findByText(/user not found/i)).toBeInTheDocument()
})

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

server.resetHandlers() in afterEach ensures these one-off overrides don't bleed into other tests.

Rule of thumb: Test at least three HTTP outcomes: success, known error (400/404), and unexpected server error (500). Each maps to a different user-facing message.

Use Vitest's (or Jest's) fake timer API to control time without actually waiting.

import { render, screen, act } from '@testing-library/react'
import { vi } from 'vitest'

function Countdown({ seconds }) {
  const [remaining, setRemaining] = React.useState(seconds)
  React.useEffect(() => {
    const id = setInterval(() => setRemaining(r => r - 1), 1000)
    return () => clearInterval(id)
  }, [])
  return <p>{remaining}s remaining</p>
}

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

  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()
  })

  test('cleans up interval on unmount', () => {
    const clearIntervalSpy = vi.spyOn(global, 'clearInterval')
    const { unmount } = render(<Countdown seconds={3} />)
    unmount()
    expect(clearIntervalSpy).toHaveBeenCalled()
  })
})

Wrapping vi.advanceTimersByTime in act() flushes any resulting React state updates synchronously.

Rule of thumb: Always call vi.useRealTimers() in afterEach — fake timers left running can cause cascading failures in later tests.

Use vi.mock() at the top of the test file (Vitest hoists it before imports). Then configure specific implementations with vi.mocked().

import { render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import * as api from '../api/userApi'   // named imports
import UserProfile from './UserProfile'

vi.mock('../api/userApi')   // auto-mocks all exports to vi.fn()

const mockedApi = vi.mocked(api)

beforeEach(() => {
  mockedApi.fetchUser.mockResolvedValue({ id: 1, name: 'Alice' })
})

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

test('fetches and displays user', async () => {
  render(<UserProfile userId={1} />)
  expect(await screen.findByText('Alice')).toBeInTheDocument()
  expect(mockedApi.fetchUser).toHaveBeenCalledWith(1)
})

test('shows error when fetch throws', async () => {
  mockedApi.fetchUser.mockRejectedValue(new Error('Network error'))
  render(<UserProfile userId={1} />)
  expect(await screen.findByText(/network error/i)).toBeInTheDocument()
})

For a partial mock (keep some real implementations):

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

Rule of thumb: Prefer MSW for HTTP mocking; use vi.mock() for non-HTTP modules (analytics SDKs, third-party clients, browser APIs).

Create a fresh QueryClient per test with retry: false (so failed queries don't retry in tests), wrap the component in a QueryClientProvider, and let MSW handle the HTTP responses.

import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import userEvent from '@testing-library/user-event'
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'
import UserList from './UserList'

function createTestClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false },    // don't retry on error in tests
      mutations: { retry: false },
    },
  })
}

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

test('renders user list from query', async () => {
  server.use(
    http.get('/api/users', () =>
      HttpResponse.json([{ id: 1, name: 'Alice' }])
    )
  )
  renderWithQuery(<UserList />)
  expect(await screen.findByText('Alice')).toBeInTheDocument()
})

test('mutation updates UI after success', async () => {
  const user = userEvent.setup()
  server.use(
    http.get('/api/todos', () => HttpResponse.json([])),
    http.post('/api/todos', () => HttpResponse.json({ id: 1, text: 'New todo' }))
  )
  renderWithQuery(<TodoApp />)
  await user.type(screen.getByRole('textbox'), 'New todo')
  await user.click(screen.getByRole('button', { name: /add/i }))
  expect(await screen.findByText('New todo')).toBeInTheDocument()
})

Rule of thumb: Always use a fresh QueryClient per test — a shared client caches query results across tests and causes false passes or unexplained failures.

Use fake timers to control the debounce delay without real waiting.

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import SearchBox from './SearchBox'  // internally debounces onSearch by 300ms

describe('SearchBox debounce', () => {
  beforeEach(() => vi.useFakeTimers())
  afterEach(() => vi.useRealTimers())

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

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

    // Before debounce fires — should not have been called
    expect(onSearch).not.toHaveBeenCalled()

    // Advance time past debounce window
    vi.runAllTimers()

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

  test('debounces rapid typing to a single call', async () => {
    const onSearch = vi.fn()
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
    render(<SearchBox onSearch={onSearch} />)

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

    vi.runAllTimers()

    // Only the final value triggers the callback
    expect(onSearch).toHaveBeenCalledTimes(1)
    expect(onSearch).toHaveBeenCalledWith('hooks')
  })
})

Key: pass { advanceTimers: vi.advanceTimersByTime } to userEvent.setup() so userEvent's internal delays use fake timers too.

Rule of thumb: Always pair fake timers with userEvent.setup({ advanceTimers }) — without it, userEvent's own async delays break fake-timer tests.

The jsdom environment (used by Vitest/Jest with RTL) provides a real localStorage implementation, but it persists between tests unless cleared. The cleanest approach is to clear it after each test.

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

test('persists theme preference to localStorage', 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 theme from localStorage on mount', () => {
  localStorage.setItem('theme', 'dark')
  render(<ThemeToggle />)
  expect(screen.getByRole('button', { name: /light mode/i })).toBeInTheDocument()
})

If you need to test a case where localStorage is unavailable (private browsing), mock it with a spy:

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

Rule of thumb: Call localStorage.clear() in afterEach, not beforeEach — clearing before masks cleanup bugs from prior tests.

The act() warning appears when a state update happens outside of a React act() call, meaning React's test utilities weren't notified that state was about to change.

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

Common causes and fixes:

1. Async state update not awaited:

// ❌ State update from async callback is not awaited
fireEvent.click(button)
// The click triggers a fetch that updates state after the test ends

// ✅ Use findBy* which internally wraps in act
await screen.findByText('Loaded')

2. Missing await on userEvent:

// ❌
user.click(button)   // forgot await

// ✅
await user.click(button)

3. useEffect with a timer not cleaned up:

// ❌ Timer fires after test cleanup
React.useEffect(() => {
  setTimeout(() => setState('done'), 500)
}, [])

// ✅ Cancel the timer in cleanup
React.useEffect(() => {
  const id = setTimeout(() => setState('done'), 500)
  return () => clearTimeout(id)
}, [])

4. Manually wrapping needed:

act(() => {
  // trigger some event that causes synchronous state updates
  eventEmitter.emit('update', newData)
})

Rule of thumb: The warning is always a signal that a state update escaped test control — find and await the async operation that triggered it.

Spy on console.error or console.warn with vi.spyOn(), suppress the output by replacing the implementation, assert the call, then restore.

import { vi } from 'vitest'

test('logs validation error for invalid prop', () => {
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

  render(<DatePicker value="not-a-date" />)

  expect(consoleSpy).toHaveBeenCalledWith(
    expect.stringContaining('Invalid date format')
  )

  consoleSpy.mockRestore()
})

For error boundaries that log the error:

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

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

  expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
  expect(errorSpy).toHaveBeenCalled()

  errorSpy.mockRestore()
})

If you want to assert console.error is NOT called (no unexpected errors):

const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
render(<CleanComponent />)
expect(consoleSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()

Rule of thumb: Always mockRestore() after spy assertions — leaving the spy in place will swallow real errors in subsequent tests.

Combine fake timers with MSW to control both the clock and the server responses. Advance time in steps and assert updated data between steps.

import { render, screen } from '@testing-library/react'
import { act } from '@testing-library/react'
import { vi } from 'vitest'
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'
import LivePriceWidget from './LivePriceWidget'  // polls /api/price every 5s

describe('LivePriceWidget', () => {
  beforeEach(() => vi.useFakeTimers())
  afterEach(() => {
    vi.useRealTimers()
    server.resetHandlers()
  })

  test('updates price after each poll interval', async () => {
    let price = 100

    server.use(
      http.get('/api/price', () => HttpResponse.json({ value: price }))
    )

    render(<LivePriceWidget />)
    expect(await screen.findByText('$100')).toBeInTheDocument()

    // Simulate a price change on the server
    price = 150
    await act(async () => {
      vi.advanceTimersByTime(5000)  // trigger next poll
    })

    expect(await screen.findByText('$150')).toBeInTheDocument()
  })
})

The await act(async () => { ... }) pattern flushes both the timer callback and the resulting async state update.

Rule of thumb: For polling tests, wrap vi.advanceTimersByTime in await act(async () => { ... }) to ensure React processes both the timer expiry and the subsequent promise resolution.

Use MSW handlers with controlled response ordering (via delay) to simulate an older request resolving after a newer one.

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { server } from '../mocks/server'
import { http, HttpResponse, delay } from 'msw'
import SearchResults from './SearchResults'

test('shows result of latest search, ignoring stale responses', async () => {
  let requestCount = 0

  server.use(
    http.get('/api/search', async ({ request }) => {
      const q = new URL(request.url).searchParams.get('q')
      requestCount++

      if (requestCount === 1) {
        // First request (stale) — slow response
        await delay(500)
        return HttpResponse.json({ query: q, results: ['Stale result'] })
      }
      // Second request (latest) — fast response
      return HttpResponse.json({ query: q, results: ['Fresh result'] })
    })
  )

  const user = userEvent.setup()
  render(<SearchResults />)

  // Type first query, then quickly change it
  await user.type(screen.getByRole('textbox'), 'slow')
  await user.clear(screen.getByRole('textbox'))
  await user.type(screen.getByRole('textbox'), 'fast')

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

This test verifies the component cancels or ignores stale requests (typically via AbortController or a flag in useEffect).

Rule of thumb: Test race conditions by controlling response timing in MSW handlers; if the test passes even without abort logic, the timing simulation isn't strict enough.

More ways to practice

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

or
Join our WhatsApp Channel