Web Development with React
Hands-onIntermediate 40 min· Building real apps · 18 of 21

Testing React components

Set up Vitest and React Testing Library in a Vite project, then write tests that act like a user: render a component, click, type, and wait for fetched data. Learn what to test, how to mock fetch, and how to read a failing test.

What you'll build: a small test suite for a Brew & Bean menu screen: an item card with an Add button, a search box that filters items, and a menu that loads data from an API. You will test each one.

Prerequisites: a React + Vite project (see *Start a React project with Vite*), and comfort with props, state and useEffect fetching. Tests are written in JavaScript; no TypeScript needed.

Why test? Tests let you change code without fear. When you refactor, a failing test tells you immediately what broke, instead of a user telling you a week later. Teams also expect tests in pull requests, and interviewers ask about them.

What to test, and how

The guiding idea of React Testing Library is: *test your components the way users use them.* A user does not call setState or check a variable. They see text, click buttons, type into boxes and wait for things to appear. So your tests should find elements by what the user sees (a button's name, a label, visible text) and not by CSS classes or component internals. Tests written this way keep passing when you refactor the code inside.

The tools:

- Vitest: the test runner (it finds *.test.jsx files, runs them, reports results). It works with Vite configuration and has a Jest-like API (describe, it, expect, vi.fn()). - React Testing Library: renders components and gives you queries and helpers. - jest-dom: extra matchers such as toBeInTheDocument() and toBeDisabled(). - user-event: simulates real user actions (typing, clicking). - jsdom: a fake browser environment inside Node, so components can render without a real browser.

Kinds of tests

KindTestsSpeedIn this lesson
UnitOne function or small componentVery fastYes
IntegrationSeveral components working together, with fake networkFastYes
End-to-end (Playwright, Cypress)The real app in a real browserSlowerMentioned only

Set up Vitest

Install and configure

  1. 1In your project folder, install the tools with the first command below.
  2. 2Edit vite.config.js so it imports defineConfig from vitest/config and has a test section (second code block). This tells Vitest to use the jsdom environment, allow global describe/it/expect, and run a setup file first.
  3. 3Create src/setupTests.js with the third code block. It adds the jest-dom matchers.
  4. 4Add a script "test": "vitest" to the scripts section of package.json.
  5. 5Run npm test. With no test files yet, Vitest reports that none were found. That is expected; it stays in watch mode, so press q to quit.
Terminal
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
vite.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/setupTests.js',
  },
});

If your existing config has other settings (a server proxy, for example), keep them and just add the test section. Keep the react plugin import that your template already has.

src/setupTests.js
import '@testing-library/jest-dom/vitest';

This registers matchers like toBeInTheDocument for every test file.

Test 1: a component with props and a click

First the component under test: a card that shows an item and calls a function when you press Add.

src/ItemCard.jsx
export default function ItemCard({ item, onAdd }) {
  return (
    <li>
      <h3>{item.name}</h3>
      <p>{`₹${item.price}`}</p>
      <button type="button" onClick={() => onAdd(item)}>
        Add {item.name}
      </button>
    </li>
  );
}

A test has three phases: arrange (render the component), act (do what a user does), assert (check the result). vi.fn() creates a spy function that records how it was called.

src/ItemCard.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import ItemCard from './ItemCard.jsx';

const espresso = { id: 'espresso', name: 'Espresso', price: 120 };

describe('ItemCard', () => {
  it('shows the name and price', () => {
    render(<ItemCard item={espresso} onAdd={() => {}} />);

    expect(screen.getByText('Espresso')).toBeInTheDocument();
    expect(screen.getByText('₹120')).toBeInTheDocument();
  });

  it('calls onAdd with the item when Add is clicked', async () => {
    const user = userEvent.setup();
    const onAdd = vi.fn();
    render(<ItemCard item={espresso} onAdd={onAdd} />);

    await user.click(screen.getByRole('button', { name: /add espresso/i }));

    expect(onAdd).toHaveBeenCalledTimes(1);
    expect(onAdd).toHaveBeenCalledWith(espresso);
  });
});

The explicit imports of describe, it, expect and vi are optional when globals is true, but they make the file clear and help your editor. getByRole('button', { name: ... }) finds the button the way a screen reader would, which also proves the button has an accessible name.

Run npm test. Vitest runs in watch mode: it re-runs affected tests whenever you save a file.

Expected result
A line for src/ItemCard.test.jsx showing 2 tests passed, then a summary like:

Test Files  1 passed (1)
     Tests  2 passed (2)

Choosing queries

Query priority (use the first that fits)

QueryFindsExample
getByRoleElements by their role and namegetByRole('button', { name: /add/i })
getByLabelTextForm fields by labelgetByLabelText('Search')
getByTextVisible textgetByText('Espresso')
getByTestIdA data-testid attribute, last resortgetByTestId('cart-count')

Each query comes in three flavours. `getBy...` returns the element or throws if it is missing. `queryBy...` returns null if missing, which you use to assert something is *not* there. `findBy...` returns a promise and waits (up to about a second) for the element to appear, which you use for things that arrive later, such as fetched data.

Test 2: typing into a search box

src/MenuSearch.jsx
import { useState } from 'react';

export default function MenuSearch({ items }) {
  const [query, setQuery] = useState('');

  const visible = items.filter((i) =>
    i.name.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <div>
      <label htmlFor="search">Search</label>
      <input
        id="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      {visible.length === 0 && <p>No items found</p>}
      <ul>
        {visible.map((i) => (
          <li key={i.id}>{i.name}</li>
        ))}
      </ul>
    </div>
  );
}
src/MenuSearch.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import MenuSearch from './MenuSearch.jsx';

const items = [
  { id: 'espresso', name: 'Espresso' },
  { id: 'masala-chai', name: 'Masala Chai' },
  { id: 'croissant', name: 'Butter Croissant' },
];

describe('MenuSearch', () => {
  it('shows every item at first', () => {
    render(<MenuSearch items={items} />);
    expect(screen.getAllByRole('listitem')).toHaveLength(3);
  });

  it('filters items as the user types', async () => {
    const user = userEvent.setup();
    render(<MenuSearch items={items} />);

    await user.type(screen.getByLabelText('Search'), 'chai');

    expect(screen.getByText('Masala Chai')).toBeInTheDocument();
    expect(screen.queryByText('Espresso')).not.toBeInTheDocument();
  });

  it('shows a message when nothing matches', async () => {
    const user = userEvent.setup();
    render(<MenuSearch items={items} />);

    await user.type(screen.getByLabelText('Search'), 'pizza');

    expect(screen.getByText('No items found')).toBeInTheDocument();
  });
});

Notice the test finds the input by its label. If someone removes the label, this test fails, and that protects accessibility as well. queryByText is used to assert absence.

Test 3: data from an API (mocking fetch)

Tests must not depend on a real server: it may be down, slow, or return different data each day. Instead, replace fetch with a fake that returns the data you choose. Then your test controls the result, and you can also test the error case.

src/Menu.jsx
import { useEffect, useState } from 'react';

export default function Menu() {
  const [items, setItems] = useState([]);
  const [status, setStatus] = useState('loading');

  useEffect(() => {
    async function load() {
      try {
        const res = await fetch('/menu');
        if (!res.ok) throw new Error('Request failed');
        setItems(await res.json());
        setStatus('ready');
      } catch {
        setStatus('error');
      }
    }
    load();
  }, []);

  if (status === 'loading') return <p>Loading menu...</p>;
  if (status === 'error') return <p>Could not load the menu.</p>;

  return (
    <ul>
      {items.map((i) => (
        <li key={i.id}>{i.name}</li>
      ))}
    </ul>
  );
}
src/Menu.test.jsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import Menu from './Menu.jsx';

afterEach(() => {
  vi.unstubAllGlobals();
});

describe('Menu', () => {
  it('shows items after loading', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => [
          { id: 'espresso', name: 'Espresso' },
          { id: 'cold-brew', name: 'Cold Brew' },
        ],
      })
    );

    render(<Menu />);

    // First the loading state is visible
    expect(screen.getByText('Loading menu...')).toBeInTheDocument();

    // findBy waits for the data to appear
    expect(await screen.findByText('Espresso')).toBeInTheDocument();
    expect(screen.getByText('Cold Brew')).toBeInTheDocument();
    expect(screen.queryByText('Loading menu...')).not.toBeInTheDocument();
  });

  it('shows an error message when the request fails', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));

    render(<Menu />);

    expect(await screen.findByText('Could not load the menu.')).toBeInTheDocument();
  });
});

vi.stubGlobal replaces window.fetch for the test and unstubAllGlobals puts the real one back afterwards. The fake only needs the fields your component actually uses: ok and json().

Mocking a whole API layer

For larger apps, a library called Mock Service Worker (MSW) intercepts network requests and lets you describe fake API responses once, which is closer to real behaviour than stubbing fetch in each test. Start with vi.stubGlobal, and look at MSW when you have many tests.

Reading a failure

Change the button text in ItemCard.jsx from Add {item.name} to Buy {item.name} and save. The click test now fails because the query for a button named /add espresso/i finds nothing. Vitest prints the query that failed and, usefully, a dump of the rendered HTML with the roles it found. Read that dump: it shows exactly what a user would see. Then decide which is wrong, the test or the component, and fix that one. If the text change was intended, update the test.

When you are stuck finding the right query, call screen.debug() to print the current HTML, or use the screen.logTestingPlaygroundURL() helper to get a link to the Testing Playground website.

Mistakes that make tests flaky or useless

1. Testing implementation details, such as the value of a state variable or a CSS class name. Test what the user sees. 2. Forgetting await before user.click or findBy. The test then finishes before the thing happens and can pass for the wrong reason. 3. Using getBy for something that appears later (use findBy), or getBy to check something is absent (use queryBy). 4. Copying snapshots everywhere. Big snapshot tests break on any change and people stop reading them. 5. Tests that depend on each other or on the real network.

How much to test

You do not need 100 percent coverage. Test the behaviour that matters and breaks often: forms and validation, calculations (such as a cart total), conditional rendering (loading, error, empty) and key user flows. Skip pure styling and trivial components. For critical journeys such as sign-up or checkout, teams add a few end-to-end tests with a tool like Playwright, which drives a real browser. That is the next step after this lesson.

In interviews you may hear "How do you test a React component?" A good answer: render it with React Testing Library, query by role or label, simulate user actions with user-event, assert on visible output, and mock the network.

Try it: Test a cart total

Write a small component CartSummary that receives items (each with name, price, qty) and shows 'Your cart is empty' when there are none, otherwise a list of lines and a total in ₹. Then write tests for:

1. The empty state. 2. Two items: Espresso (₹120 x 2) and Butter Croissant (₹150 x 1) show a total of ₹390. 3. Add a Clear cart button that calls an onClear prop, and test it with vi.fn().

Finally, break the total calculation on purpose and confirm that a test fails.

Prompt · Write tests for my component
You are a senior React developer who uses Vitest and React Testing Library. Write tests for the component below. Rules: query by role, label or text (not by class names); use userEvent with await; cover the normal case, the empty case and one error case; mock fetch with vi.stubGlobal if the component fetches. After the code, list what each test protects against.

```
[paste your component]
```

What to expect: Run the tests. Then break the component on purpose to check that at least one test fails; a test that cannot fail is worthless. Remove any test that checks implementation details.

Key takeaways

  1. Test components the way users use them: find by role, label or text, then click and type with user-event.
  2. Vitest runs the tests, Testing Library renders and queries, jest-dom adds readable matchers, jsdom fakes the browser.
  3. Use getBy when an element must exist, queryBy to assert absence, findBy to wait for async results.
  4. Mock fetch so tests are fast and predictable, and test loading and error states, not only success.
  5. Always await user actions and findBy queries.
  6. Test behaviour that matters, not every line; add a few end-to-end tests for critical flows.

Check yourself

Finishing the quiz marks the lesson as done.

1. Which query best matches the idea of testing like a user?

2. A product list appears only after a fetch completes. Which query should you use to check it?

3. How do you assert that an element is NOT on the screen?

4. Why mock fetch in a component test?

5. A test calls user.click(button) without await and then asserts the result. What is the risk?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free