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:
awaiteveryuserEventcall.- Use
findBy*(which internally awaits) instead ofgetBy*for async elements. - Wrap timer advances in
act(() => vi.advanceTimersByTime(...)). - Cancel timers and subscriptions in
useEffectcleanup.
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()andvi.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
QueryClientper React Query test. - Clear
localStorageafter each test. - Suppress
console.errorfor 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.