Testing Async Code — API Calls, Loading States, Error States
Day 34 of 40 — React System Design Series

Hi, I’m Richa — a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons I’ve learned working on large codebases. My goal is to share practical insights — not shortcuts — for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If you’re serious about improving as an engineer, you’re in the right place.
Hey everyone, Richa here! 👋

Day 34.
Most React apps are async apps. Data fetches. Form submissions. Real-time updates. And the hardest tests to get right are the ones that involve waiting — for data to load, for errors to appear, for success messages to show.
Today we nail async testing. Loading states, error states, success states, mocking APIs, and the common traps that cause tests to either flake or never actually test what you think they test.
The async testing tools
// Three ways to wait in RTL
// 1. findBy — wait for element to appear (auto-waits up to 1 second)
const element = await screen.findByText('Data loaded');
// 2. waitFor — poll until assertion passes
await waitFor(() => expect(screen.getByText('Done')).toBeInTheDocument());
// 3. waitForElementToBeRemoved — wait for element to disappear
await waitForElementToBeRemoved(() => screen.getByText('Loading...'));
When to use which:
findBy— when you're waiting for a specific element to appearwaitFor— when you need to assert on multiple things or a non-element conditionwaitForElementToBeRemoved— when testing that a loading spinner disappears
Mocking API calls — the three approaches
Option 1 — Mock the service module (simplest)
// vi.mock auto-mocks the module, then you control the return value per test
vi.mock('@/services/userService');
test('shows users after fetch', async () => {
vi.mocked(userService.getUsers).mockResolvedValue([
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
]);
render(<UserList />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
Option 2 — Mock at the fetch level (for apps using raw fetch)
global.fetch = vi.fn();
test('fetches and renders', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ users: [{ id: '1', name: 'Alice' }] }),
} as Response);
render(<UserList />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});
Option 3 — MSW (Mock Service Worker) — for integration/E2E-like tests
MSW intercepts real HTTP requests and returns mock responses at the network level — no code changes, no mocking of modules.
npm install --save-dev msw
// src/test/mswServer.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
export const server = setupServer(
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
]);
}),
http.post('/api/auth/login', () => {
return HttpResponse.json({ id: '1', name: 'Richa', email: 'richa@example.com' });
}),
http.get('/api/users/:id', ({ params }) => {
if (params.id === '999') {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json({ id: params.id, name: 'Alice' });
}),
);
// src/test/setup.ts
import { server } from './mswServer';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // reset any per-test overrides
afterAll(() => server.close());
MSW is the gold standard for testing async code. Your tests use the real fetch, the real axios, or React Query — exactly as in production. Only the network layer is intercepted.
Testing loading states
// components/ProductsPage.tsx (simplified)
function ProductsPage() {
const { data: products, isLoading, isError } = useProducts();
if (isLoading) return <div>Loading products...</div>;
if (isError) return <div role="alert">Failed to load products</div>;
return (
<ul>
{products!.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
// ProductsPage.test.tsx
import { render, screen, waitForElementToBeRemoved } from '@/test/test-utils';
import { server } from '@/test/mswServer';
import { http, HttpResponse } from 'msw';
import { ProductsPage } from './ProductsPage';
describe('ProductsPage', () => {
it('shows loading state then renders products', async () => {
// Add a delay to make the loading state visible
server.use(
http.get('/api/products', async () => {
await new Promise(r => setTimeout(r, 100));
return HttpResponse.json([
{ id: '1', name: 'Widget' },
{ id: '2', name: 'Gadget' },
]);
})
);
render(<ProductsPage />);
// Loading state is visible
expect(screen.getByText('Loading products...')).toBeInTheDocument();
// Wait for loading to finish
await waitForElementToBeRemoved(() => screen.getByText('Loading products...'));
// Products are now shown
expect(screen.getByText('Widget')).toBeInTheDocument();
expect(screen.getByText('Gadget')).toBeInTheDocument();
});
it('shows error state when fetch fails', async () => {
server.use(
http.get('/api/products', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<ProductsPage />);
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Failed to load products');
});
it('renders products successfully', async () => {
// Default MSW handler returns products — no override needed
render(<ProductsPage />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});
});
Testing form submission
The full async form test — loading, success, error, back to normal:
describe('ContactForm', () => {
it('shows success message after submission', async () => {
server.use(
http.post('/api/contact', () => HttpResponse.json({ success: true }))
);
render(<ContactForm />);
await userEvent.type(screen.getByLabelText(/name/i), 'Richa');
await userEvent.type(screen.getByLabelText(/message/i), 'Hello from the tests!');
await userEvent.click(screen.getByRole('button', { name: /send/i }));
// Wait for success
expect(await screen.findByText('Message sent!')).toBeInTheDocument();
// Form fields are cleared after success
expect(screen.getByLabelText(/name/i)).toHaveValue('');
});
it('disables submit button while submitting', async () => {
server.use(
http.post('/api/contact', async () => {
await new Promise(r => setTimeout(r, 200));
return HttpResponse.json({ success: true });
})
);
render(<ContactForm />);
await userEvent.type(screen.getByLabelText(/name/i), 'Richa');
await userEvent.type(screen.getByLabelText(/message/i), 'Test');
const submitButton = screen.getByRole('button', { name: /send/i });
await userEvent.click(submitButton);
// Button is disabled during submission
expect(submitButton).toBeDisabled();
// Wait for submission to complete
await screen.findByText('Message sent!');
// Button is re-enabled
expect(submitButton).not.toBeDisabled();
});
it('shows validation errors for empty fields', async () => {
render(<ContactForm />);
await userEvent.click(screen.getByRole('button', { name: /send/i }));
expect(await screen.findByText('Name is required')).toBeInTheDocument();
expect(screen.getByText('Message is required')).toBeInTheDocument();
});
});
The act() warning — what it means and how to fix it
If you see this in test output:
Warning: An update to Component inside a test was not wrapped in act(...)
It means a state update happened outside React's control. Usually: a promise resolved after the test ended, or an async operation you forgot to await.
// ❌ Causes act() warning — test ends before async operation completes
it('loads data', () => {
render(<DataComponent />);
// test ends here — but React Query is still fetching in the background
});
// ✅ Fix — await the async content
it('loads data', async () => {
render(<DataComponent />);
await screen.findByText('Data loaded'); // waits for async rendering
});
The rule: if a component has async operations (data fetching, timers), your test must wait for them to complete before the test ends.
Common async testing mistakes
Mistake 1 — Forgetting to await findBy
// ❌ findBy returns a Promise — must await it
expect(screen.findByText('Done')).toBeInTheDocument(); // always passes — checking a Promise, not the element
// ✅ Await the element
expect(await screen.findByText('Done')).toBeInTheDocument();
Mistake 2 — Using getBy for async content
// ❌ getBy is synchronous — throws immediately if not in DOM
expect(screen.getByText('Data loaded')).toBeInTheDocument(); // fails if render is async
// ✅ findBy waits
expect(await screen.findByText('Data loaded')).toBeInTheDocument();
Mistake 3 — Not resetting MSW handlers between tests
// Without this, a test that adds a handler override pollutes the next test
afterEach(() => server.resetHandlers());
Today's takeaway
Async testing comes down to two things:
- Mock the network — MSW at the network level is cleaner than mocking modules; per-test overrides with
server.use() - Wait for what the user sees —
findByfor elements appearing,waitForElementToBeRemovedfor elements disappearing,waitForfor complex assertions
The most common mistake: forgetting to await async operations, causing tests to pass for the wrong reason or generate act() warnings.

See you tomorrow for Day 35 — E2E testing with Playwright: your first real end-to-end test.
← Day 33 — Testing Custom Hooks → Day 35 — E2E with Playwright
Part of the React System Design Series — 40 days, one topic per day.





