Testing React Apps with Vitest and React Testing Library

React.js Full Course: Beginner to Advanced · Advanced React · 11 min read · Updated

A test acts like a user

  1. A test draws your component in a fake browser.
  2. It finds elements the way people do: by role, label and text, not by internal names.
  3. It clicks and types, with user-event, like a real person.
  4. Then it checks what is on the screen with expect.
  5. Test the user, not the code inside. Then you can tidy the code without breaking tests.

A test is a small program that checks that your code still works. When you change something next month, tests tell you at once if you broke something else. Many beginners skip tests because they feel like extra work. In this lesson you will see that a few good tests are quick to write, and that they make changing code much less scary.

What should you test?

Do not try to test everything. Test what matters to the user, and what is easy to break:

  • Plain functions with rules, such as validation or price calculations. These are the easiest and most valuable tests.
  • Components the way a person uses them: the person sees text, clicks buttons and types in fields.
  • Important flows: sign in, add to cart, submit a form, an error message after a failed request.

A good guide from the testing library's own creators: test what the user sees and does, not how your code is built inside. If you test internal details like state names, your tests break when you tidy your code, even though the app still works. Those are bad tests.

The tools

  • Vitest is the test runner. It works with Vite, so it needs almost no setup, and it is very fast. (Jest is an older, similar tool you may see in other projects.)
  • React Testing Library draws your component in a fake browser and gives you ways to find things the way a user would: by text, by label and by role.
  • jest-dom adds helpful checks like “is this in the page?”.
  • user-event pretends to be a real person clicking and typing.
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

Then add this to vite.config.js, and a setup file:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

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

// src/test/setup.js
import '@testing-library/jest-dom/vitest';
// package.json scripts
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run"
  }
}

npm test starts Vitest in watch mode: it reruns the tests you changed as you save. npm run test:run runs once, which is what you use in a build.

Your first test: a plain function

// price.js
export function totalPrice(items) {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

// price.test.js
import { describe, it, expect } from 'vitest';
import { totalPrice } from './price';

describe('totalPrice', () => {
  it('adds up price times quantity', () => {
    const items = [
      { price: 10, quantity: 2 },
      { price: 5, quantity: 1 },
    ];
    expect(totalPrice(items)).toBe(25);
  });

  it('returns 0 for an empty cart', () => {
    expect(totalPrice([])).toBe(0);
  });
});
  • describe groups tests. it (or test) is one test with a clear sentence about what should happen.
  • expect(value).toBe(expected) is an assertion. If it is not true, the test fails and shows you the difference.

Testing a component

// Counter.jsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Add one</button>
    </div>
  );
}

// Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

it('adds one when the button is clicked', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  expect(screen.getByText('Count: 0')).toBeInTheDocument();

  await user.click(screen.getByRole('button', { name: 'Add one' }));

  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

The test follows what a person does: draw the component, see “Count: 0”, click the button named “Add one”, see “Count: 1”. It says nothing about useState. If you later rewrite the counter with useReducer, this test still passes, which is exactly the point.

Finding things: the query priority

Testing Library suggests finding elements the way people and assistive tools do. Use them in this order:

  1. getByRole('button', { name: 'Save' }): the best. It also checks that your page is accessible.
  2. getByLabelText('Email'): for form fields.
  3. getByText('Welcome'): for text content.
  4. getByPlaceholderText, getByAltText, getByTitle: when the above do not fit.
  5. getByTestId('x'): last resort, using a data-testid attribute.

There are three families: `getBy` throws if the thing is missing, `queryBy` returns null (use it to check that something is not there), and `findBy` waits for it to appear (use it for async changes).

Testing a form

it('shows an error for a short password and submits when valid', async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn();
  render(<SignUpForm onSubmit={onSubmit} />);

  await user.type(screen.getByLabelText('Email'), 'asha@example.com');
  await user.type(screen.getByLabelText('Password'), 'short');
  await user.click(screen.getByRole('button', { name: 'Create account' }));

  expect(screen.getByRole('alert')).toHaveTextContent('at least 8 characters');
  expect(onSubmit).not.toHaveBeenCalled();

  await user.clear(screen.getByLabelText('Password'));
  await user.type(screen.getByLabelText('Password'), 'longenough123');
  await user.click(screen.getByRole('button', { name: 'Create account' }));

  expect(onSubmit).toHaveBeenCalledTimes(1);
});

vi.fn() makes a mock function: a fake function that remembers how it was called. We pass it in as onSubmit and check that it was (or was not) called. Notice also that finding fields by label forces your form to have proper labels.

Testing async data

For components that load data, do not call the real server in a test. It would be slow and unreliable. Replace fetch with a fake:

import { vi } from 'vitest';

it('shows posts after loading', async () => {
  vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
    ok: true,
    json: async () => [{ id: 1, title: 'First post' }],
  }));

  render(<Posts />);

  expect(screen.getByText('Loading...')).toBeInTheDocument();
  expect(await screen.findByText('First post')).toBeInTheDocument();
});

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

  render(<Posts />);

  expect(await screen.findByRole('alert')).toHaveTextContent('Could not load');
});

findByText waits (for up to a second by default) until the text appears. Testing both the happy path and the error path is important, because errors are where real apps break. For bigger projects, a tool called MSW (Mock Service Worker) fakes the network at a deeper level, so you do not need to stub fetch in every test.

Testing a custom hook

import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';

it('toggles the value', () => {
  const { result } = renderHook(() => useToggle(false));

  expect(result.current[0]).toBe(false);

  act(() => result.current[1]());

  expect(result.current[0]).toBe(true);
});

renderHook runs your hook inside a tiny test component. act makes sure React has finished updating before you check the result. Testing Library's own helpers like user.click already wrap act for you.

Testing with context and a router

A component that uses context or React Router needs the provider around it. Make a small helper:

import { MemoryRouter } from 'react-router-dom';

function renderWithProviders(ui, { route = '/' } = {}) {
  return render(
    <MemoryRouter initialEntries={[route]}>
      <AuthProvider>{ui}</AuthProvider>
    </MemoryRouter>
  );
}

MemoryRouter is a router that keeps the address in memory, so no real browser is needed.

What makes a good test

  • It has a clear name that reads like a sentence: “shows an error for a short password”.
  • It tests one idea.
  • It does not depend on other tests or on the order they run.
  • It uses no real network, timers or dates when it can fake them.
  • It would still pass if you rewrote the inside of the component but kept the behaviour.

Do not chase 100 percent coverage. A few tests on your most important paths are worth more than many tests on trivial things. When you fix a bug, write a test that fails because of the bug first. Then fix it. The bug can never silently return.

Beyond component tests

  • End-to-end tests with Playwright or Cypress open a real browser and click through your whole app, including the server. They are slower, so write a few for the most important journeys, such as sign up and checkout.
  • Continuous integration (CI) runs your tests automatically on every push, for example with GitHub Actions, so a broken change is caught before it goes live.

Common mistakes

  • Testing internal state or function names instead of what the user sees.
  • Using getByTestId for everything, which skips accessibility checks.
  • Forgetting await with user.click and findBy, so the test checks too early.
  • Calling real APIs in tests.
  • Tests that depend on each other, or on the current date.

Your task

  1. Install Vitest and Testing Library, and write two tests for a totalPrice function.
  2. Write a test for a counter: click, and check the new text.
  3. Write a test for your sign-up form: a short password shows an error, and a good one calls onSubmit.
  4. Write a test for a component that loads data, with a fake fetch, for both success and failure.

How to check it worked

  • npm run test:run passes and prints a list of your tests.
  • Changing the inside of a component (for example from useState to useReducer) does not break its test.
  • You can break a feature on purpose and see a test fail.

Summary

  • Test what users see and do. Start with plain functions, then the key components and flows.
  • Use Vitest to run tests, React Testing Library to draw components, and user-event to click and type.
  • Find elements by role, label and text. Use findBy for async changes and queryBy to check something is absent.
  • Fake the network. Test both success and error paths. Run tests in CI.

Frequently asked questions

Should I use Jest or Vitest?

Vitest works with Vite and needs little setup. Jest is older and also common. The API is very similar, so skills carry over.

What is React Testing Library?

A library that draws components in a test environment and finds elements the way a user would, by role, label and text.

How many tests should I write?

Enough to protect your important flows and rules. Chasing 100 percent coverage wastes time. When you fix a bug, add a test for it.

Why is my async test failing?

You probably forgot await, or used getBy instead of findBy for something that appears later.