Skip to main content

Command Palette

Search for a command to run...

Testing Async Code — API Calls, Loading States, Error States

Day 34 of 40 — React System Design Series

Updated
7 min read
Testing Async Code — API Calls, Loading States, Error States
R

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! 👋

async gif

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 appear
  • waitFor — when you need to assert on multiple things or a non-element condition
  • waitForElementToBeRemoved — 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:

  1. Mock the network — MSW at the network level is cleaner than mocking modules; per-test overrides with server.use()
  2. Wait for what the user seesfindBy for elements appearing, waitForElementToBeRemoved for elements disappearing, waitFor for complex assertions

The most common mistake: forgetting to await async operations, causing tests to pass for the wrong reason or generate act() warnings.

async done gif

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.

React System Design — Learning in Public

Part 33 of 34

I'm a React developer with 5.5 years of experience who spent 2 years in Lit.js — and slowly forgot how React really works at a senior level. This series is my public journey back. Every day I cover one topic: state management, authentication, performance, system design interviews, testing — the real stuff that separates a mid-level dev from a senior. Written honestly, with real code, for developers at every level.

Up next

Where to Go From Here — Next.js, Micro-Frontends, and What's Actually Worth Learning

Day 40 of 40 — React System Design Series